fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由

This commit is contained in:
qyyfw committed 2026-07-11 11:40:39 +08:00
1 parent d95b847fee
commit e8e5ffa1a9
65 files changed
+35624 -339

No files matched your search

+491
View File
@@ -0,0 +1,491 @@
import { useState, useEffect, useCallback } from 'react';
import { request, adminAPI } from '@/api';
// ==================== Types ====================
interface UserOverview {
user: { id: string; username: string; email: string; avatar: string | null; role: string; membership_level: number; membership_expire_at: string | null; created_at: string; };
token_stats: { total_tokens: number; prompt_tokens: number; completion_tokens: number; call_count: number; };
daily_usage: { date: string; calls: number; tokens: number; }[];
tenants: { id: string; name: string; slug: string; status: string; role: string; }[];
recent_transactions: { id: string; type: string; amount: number; tokens: number; description: string; status: string; created_at: string; }[];
api_key_count: number;
}
interface ApiKey { id: string; key_name: string; key_prefix: string; status: string; created_at: string; last_used_at: string | null; }
interface Package { id: string; name: string; description: string; tokens: number; price: number; original_price: number | null; bonus_tokens: number; is_popular: boolean; }
interface Tenant { id: string; name: string; slug: string; description: string; status: string; max_members: number; max_storage_mb: number; max_api_calls_per_day: number; role: string; joined_at: string; member_count: number; }
interface UsageData { daily: { date: string; calls: number; tokens: number; prompt_tokens: number; completion_tokens: number; }[]; model_distribution: { model: string; calls: number; tokens: number; }[]; total: { calls: number; tokens: number; }; days: number; }
const API_BASE = '/app/api/portal';
// ==================== Component ====================
export default function UserPortal() {
const [activeTab, setActiveTab] = useState('overview');
const [loading, setLoading] = useState(false);
const tabs = [
{ key: 'overview', label: '概览', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ key: 'apikeys', label: 'API密钥', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ key: 'recharge', label: '充值中心', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
{ key: 'tenants', label: '我的租户', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5' },
{ key: 'analytics', label: '用量分析', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
];
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#e6edf3', padding: '24px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ fontSize: '28px', marginBottom: '24px' }}>用户门户</h1>
{/* Tab Navigation */}
<div style={{ display: 'flex', gap: '4px', borderBottom: '1px solid #30363d', marginBottom: '24px' }}>
{tabs.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px', background: 'none', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #58a6ff' : '2px solid transparent',
color: activeTab === tab.key ? '#58a6ff' : '#8b949e', cursor: 'pointer', fontSize: '14px', fontWeight: 600,
}}>
{tab.label}
</button>
))}
</div>
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'apikeys' && <ApiKeysTab />}
{activeTab === 'recharge' && <RechargeTab />}
{activeTab === 'tenants' && <TenantsTab />}
{activeTab === 'analytics' && <AnalyticsTab />}
</div>
</div>
);
}
// ==================== Overview Tab ====================
function OverviewTab() {
const [data, setData] = useState<UserOverview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<any>(`${API_BASE}/overview`).then(res => {
setData(res.data || res);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
if (loading) return <div style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>加载中...</div>;
if (!data) return <div style={{ padding: '40px', textAlign: 'center', color: '#f85149' }}>加载失败</div>;
const cards = [
{ label: '本月Token用量', value: data.token_stats.total_tokens.toLocaleString(), color: '#58a6ff' },
{ label: 'API调用次数', value: data.token_stats.call_count.toLocaleString(), color: '#3fb950' },
{ label: 'API密钥数', value: data.api_key_count.toString(), color: '#bc8cff' },
{ label: '所属租户', value: data.tenants.length.toString(), color: '#f0883e' },
];
return (
<div>
{/* Stats Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{cards.map((c, i) => (
<div key={i} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
<div style={{ color: c.color, fontSize: '28px', fontWeight: 700 }}>{c.value}</div>
<div style={{ color: '#8b949e', fontSize: '13px', marginTop: '4px' }}>{c.label}</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
{/* Daily Usage Chart */}
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>近7天用量</h3>
{data.daily_usage.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '120px' }}>
{data.daily_usage.map((d, i) => {
const maxTokens = Math.max(...data.daily_usage.map(x => x.tokens), 1);
const height = (d.tokens / maxTokens) * 100;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '100%', height: `${height}%`, background: '#58a6ff', borderRadius: '4px 4px 0 0', minHeight: '4px' }} />
<div style={{ fontSize: '10px', color: '#8b949e', marginTop: '4px' }}>{d.date.slice(5)}</div>
</div>
);
})}
</div>
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
</div>
{/* Recent Transactions */}
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>最近交易</h3>
{data.recent_transactions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{data.recent_transactions.map(t => (
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #21262d' }}>
<div>
<div style={{ fontSize: '13px' }}>{t.description}</div>
<div style={{ fontSize: '11px', color: '#8b949e' }}>{t.created_at}</div>
</div>
<div style={{ color: t.amount >= 0 ? '#3fb950' : '#f85149', fontWeight: 600, fontSize: '14px' }}>
{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}
</div>
</div>
))}
</div>
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无交易</div>}
</div>
</div>
{/* Tenants */}
{data.tenants.length > 0 && (
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginTop: '16px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>我的租户</h3>
<div style={{ display: 'flex', gap: '12px' }}>
{data.tenants.map(t => (
<div key={t.id} style={{ padding: '12px 16px', background: '#21262d', borderRadius: '8px', flex: 1 }}>
<div style={{ fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: '12px', color: '#8b949e' }}>角色: {t.role}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ==================== API Keys Tab ====================
function ApiKeysTab() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKey, setNewKey] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
request<any>(`${API_BASE}/api-keys`).then(res => {
setKeys(res.data || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const createKey = () => {
if (!newKeyName.trim()) return;
request<any>(`${API_BASE}/api-keys/create?key_name=${encodeURIComponent(newKeyName)}`, { method: 'POST' }).then(res => {
setNewKey(res.data?.api_key || null);
setNewKeyName('');
setShowCreate(false);
load();
});
};
const revokeKey = (id: string) => {
if (!confirm('确定撤销此密钥?')) return;
request<any>(`${API_BASE}/api-keys/${id}`, { method: 'DELETE' }).then(() => load());
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 style={{ fontSize: '20px' }}>API 密钥管理</h2>
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
创建密钥
</button>
</div>
{newKey && (
<div style={{ background: '#1a2332', border: '1px solid #58a6ff', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<div style={{ color: '#58a6ff', fontWeight: 600, marginBottom: '8px' }}>密钥已创建 - 请立即保存</div>
<code style={{ display: 'block', padding: '8px', background: '#0d1117', borderRadius: '4px', wordBreak: 'break-all', fontSize: '13px' }}>{newKey}</code>
<button onClick={() => setNewKey(null)} style={{ marginTop: '8px', padding: '4px 12px', background: '#30363d', color: '#e6edf3', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>我已保存</button>
</div>
)}
{showCreate && (
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<input value={newKeyName} onChange={e => setNewKeyName(e.target.value)} placeholder="密钥名称(如:生产环境)"
style={{ width: '300px', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginRight: '8px' }} />
<button onClick={createKey} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
</div>
)}
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div> : (
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>名称</th>
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>密钥前缀</th>
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>状态</th>
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>创建时间</th>
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>操作</th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无API密钥</td></tr>
) : keys.map(k => (
<tr key={k.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: '12px' }}>{k.key_name}</td>
<td style={{ padding: '12px' }}><code style={{ color: '#f0883e' }}>{k.key_prefix}...</code></td>
<td style={{ padding: '12px' }}>
<span style={{ padding: '2px 8px', borderRadius: '12px', fontSize: '12px', background: k.status === 'active' ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', color: k.status === 'active' ? '#3fb950' : '#f85149' }}>
{k.status === 'active' ? '活跃' : '已撤销'}
</span>
</td>
<td style={{ padding: '12px', color: '#8b949e' }}>{k.created_at}</td>
<td style={{ padding: '12px' }}>
{k.status === 'active' && <button onClick={() => revokeKey(k.id)} style={{ padding: '4px 10px', background: 'rgba(248,81,73,0.1)', color: '#f85149', border: '1px solid rgba(248,81,73,0.3)', borderRadius: '4px', cursor: 'pointer' }}>撤销</button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ==================== Recharge Tab ====================
function RechargeTab() {
const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<any>(`${API_BASE}/billing/packages`).then(res => {
setPackages(res.data || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
if (loading) return <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div>;
return (
<div>
<h2 style={{ fontSize: '20px', marginBottom: '24px' }}>充值中心</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
{packages.map(pkg => (
<div key={pkg.id} style={{
background: '#161b22', border: pkg.is_popular ? '2px solid #58a6ff' : '1px solid #30363d',
borderRadius: '12px', padding: '24px', position: 'relative',
}}>
{pkg.is_popular && <div style={{ position: 'absolute', top: '-10px', left: '50%', transform: 'translateX(-50%)', background: '#58a6ff', color: '#0d1117', padding: '2px 12px', borderRadius: '10px', fontSize: '12px', fontWeight: 600 }}>推荐</div>}
<h3 style={{ fontSize: '18px', marginBottom: '8px' }}>{pkg.name}</h3>
<p style={{ color: '#8b949e', fontSize: '13px', minHeight: '40px' }}>{pkg.description}</p>
<div style={{ margin: '16px 0' }}>
<span style={{ fontSize: '32px', fontWeight: 700, color: '#58a6ff' }}>¥{pkg.price}</span>
{pkg.original_price && <span style={{ marginLeft: '8px', color: '#8b949e', textDecoration: 'line-through' }}>¥{pkg.original_price}</span>}
</div>
<div style={{ color: '#3fb950', fontSize: '14px', marginBottom: '16px' }}>
{(pkg.tokens / 10000).toFixed(0)} 万 Token{pkg.bonus_tokens > 0 && ` +${(pkg.bonus_tokens / 10000).toFixed(0)}万 赠送`}
</div>
<button style={{
width: '100%', padding: '10px', background: pkg.is_popular ? '#58a6ff' : '#238636',
color: pkg.is_popular ? '#0d1117' : '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
}}>立即购买</button>
</div>
))}
</div>
<div style={{ marginTop: '32px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>交易记录</h3>
<TransactionsList />
</div>
</div>
);
}
function TransactionsList() {
const [txns, setTxns] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<any>(`${API_BASE}/billing/transactions`).then(res => {
setTxns(res.data?.items || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
if (loading) return <div style={{ color: '#8b949e' }}>加载中...</div>;
return (
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr style={{ borderBottom: '1px solid #30363d' }}>
{['描述', '类型', '金额', 'Token', '状态', '时间'].map(h => <th key={h} style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e' }}>{h}</th>)}
</tr></thead>
<tbody>
{txns.length === 0 ? <tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无交易记录</td></tr> : txns.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: '12px' }}>{t.description}</td>
<td style={{ padding: '12px' }}>{t.type}</td>
<td style={{ padding: '12px', color: t.amount >= 0 ? '#3fb950' : '#f85149' }}>{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}</td>
<td style={{ padding: '12px' }}>{t.tokens?.toLocaleString() || '-'}</td>
<td style={{ padding: '12px' }}><span style={{ color: t.status === 'completed' ? '#3fb950' : '#d29922' }}>{t.status}</span></td>
<td style={{ padding: '12px', color: '#8b949e' }}>{t.created_at}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ==================== Tenants Tab ====================
function TenantsTab() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [desc, setDesc] = useState('');
const load = useCallback(() => {
setLoading(true);
request<any>(`${API_BASE}/tenants`).then(res => {
setTenants(res.data || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const create = () => {
if (!name.trim() || !slug.trim()) return;
request<any>(`${API_BASE}/tenants/create?name=${encodeURIComponent(name)}&slug=${encodeURIComponent(slug)}&description=${encodeURIComponent(desc)}`, { method: 'POST' }).then(() => {
setShowCreate(false); setName(''); setSlug(''); setDesc('');
load();
});
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 style={{ fontSize: '20px' }}>我的租户</h2>
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>创建租户</button>
</div>
{showCreate && (
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '20px', marginBottom: '16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="租户名称" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
<input value={slug} onChange={e => setSlug(e.target.value)} placeholder="标识(如:my-company)" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
</div>
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(选填)" style={{ width: '100%', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginBottom: '12px' }} />
<button onClick={create} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
</div>
)}
{loading ? <div style={{ color: '#8b949e', padding: '40px', textAlign: 'center' }}>加载中...</div> : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
{tenants.length === 0 ? <div style={{ color: '#8b949e', gridColumn: '1/3', textAlign: 'center', padding: '40px' }}>暂无租户,点击上方创建</div> : tenants.map(t => (
<div key={t.id} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
<h3 style={{ fontSize: '18px' }}>{t.name}</h3>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: t.role === 'owner' ? 'rgba(188,140,255,0.15)' : 'rgba(88,166,255,0.15)', color: t.role === 'owner' ? '#bc8cff' : '#58a6ff' }}>{t.role}</span>
</div>
<p style={{ color: '#8b949e', fontSize: '13px', marginBottom: '12px' }}>{t.description || '暂无描述'}</p>
<div style={{ display: 'flex', gap: '16px', fontSize: '13px', color: '#8b949e' }}>
<span>成员: {t.member_count}/{t.max_members}</span>
<span>存储: {(t.max_storage_mb / 1024).toFixed(0)}GB</span>
<span>API/日: {t.max_api_calls_per_day.toLocaleString()}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ==================== Analytics Tab ====================
function AnalyticsTab() {
const [data, setData] = useState<UsageData | null>(null);
const [days, setDays] = useState(30);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
request<any>(`${API_BASE}/analytics/usage?days=${days}`).then(res => {
setData(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, [days]);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '24px' }}>
<h2 style={{ fontSize: '20px' }}>用量分析</h2>
<select value={days} onChange={e => setDays(Number(e.target.value))} style={{ padding: '8px 12px', background: '#161b22', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }}>
<option value={7}>最近7天</option>
<option value={14}>最近14天</option>
<option value={30}>最近30天</option>
<option value={90}>最近90天</option>
</select>
</div>
{loading ? <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>加载中...</div> : data ? (
<div>
{/* Total Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#58a6ff' }}>{data.total.calls.toLocaleString()}</div>
<div style={{ color: '#8b949e', fontSize: '13px' }}>API调用总数</div>
</div>
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#3fb950' }}>{data.total.tokens.toLocaleString()}</div>
<div style={{ color: '#8b949e', fontSize: '13px' }}>Token消耗总数</div>
</div>
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#bc8cff' }}>{data.daily.length}</div>
<div style={{ color: '#8b949e', fontSize: '13px' }}>活跃天数</div>
</div>
</div>
{/* Daily Usage Chart */}
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>每日用量趋势</h3>
{data.daily.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '4px', height: '150px' }}>
{data.daily.map((d, i) => {
const max = Math.max(...data.daily.map(x => x.tokens), 1);
const h = (d.tokens / max) * 100;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }} title={`${d.date}: ${d.tokens} tokens`}>
<div style={{ width: '100%', height: `${h}%`, background: 'linear-gradient(180deg, #58a6ff, #1f6feb)', borderRadius: '4px 4px 0 0', minHeight: '2px' }} />
{data.daily.length <= 14 && <div style={{ fontSize: '9px', color: '#8b949e', marginTop: '2px', transform: 'rotate(-45deg)' }}>{d.date.slice(5)}</div>}
</div>
);
})}
</div>
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
</div>
{/* Model Distribution */}
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>模型使用分布</h3>
{data.model_distribution.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{data.model_distribution.map((m, i) => {
const maxTokens = Math.max(...data.model_distribution.map(x => x.tokens), 1);
const pct = (m.tokens / maxTokens) * 100;
const colors = ['#58a6ff', '#3fb950', '#bc8cff', '#f0883e', '#d29922'];
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: '120px', fontSize: '13px' }}>{m.model}</div>
<div style={{ flex: 1, height: '20px', background: '#21262d', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: colors[i % colors.length] }} />
</div>
<div style={{ width: '100px', fontSize: '12px', color: '#8b949e', textAlign: 'right' }}>{m.tokens.toLocaleString()} tokens</div>
</div>
);
})}
</div>
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
</div>
</div>
) : <div style={{ color: '#f85149', textAlign: 'center', padding: '40px' }}>加载失败</div>}
</div>
);
}
File diff suppressed because it is too large. Load diff
+701
View File
@@ -0,0 +1,701 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface Agent {
id: string;
name: string;
type: string;
source: string;
publisher_id: string;
publisher_name?: string;
description?: string;
icon?: string;
tutorial?: string;
price: number;
is_member_free: boolean;
join_promotion: boolean;
status: string;
rating_avg: number;
review_count: number;
install_count: number;
skill_content?: string;
agent_endpoint?: string;
config?: AgentConfig;
linked_skills?: LinkedSkill[];
created_at?: string;
updated_at?: string;
}
interface AgentConfig {
system_prompt?: string;
temperature?: number;
max_tokens?: number;
model_id?: string;
skills?: string[];
tools?: string[];
memory_enabled?: boolean;
reasoning_enabled?: boolean;
}
interface LinkedSkill {
id: string;
skill_name: string;
state: string;
version: number;
}
interface AgentStats {
total_agents: number;
published_agents: number;
pending_agents: number;
official_agents: number;
user_agents: number;
total_installs: number;
average_rating: number;
by_source: Record<string, number>;
}
interface AvailableModel {
id: string;
name: string;
provider: string;
model_type?: string;
}
const AGENT_TYPES = [
{ id: 'skill', label: '技能' },
{ id: 'agent', label: 'Agent' },
{ id: 'mcp', label: 'MCP' },
];
const Agents: React.FC = () => {
const [agents, setAgents] = useState<Agent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<AgentStats | null>(null);
// Filters
const [keyword, setKeyword] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSource, setFilterSource] = useState('');
// Modal
const [showModal, setShowModal] = useState(false);
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
icon: '',
price: '0',
is_member_free: false,
system_prompt: '',
temperature: '0.7',
max_tokens: '2048',
model_id: '',
skills: [] as string[],
memory_enabled: false,
reasoning_enabled: false,
});
// Detail drawer
const [showDetail, setShowDetail] = useState(false);
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
// Available skills for linking
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load agents
const loadAgents = async () => {
setLoading(true);
try {
const params: any = { page, page_size: 15 };
if (keyword) params.keyword = keyword;
if (filterStatus) params.status = filterStatus;
if (filterSource) params.source = filterSource;
const result = await adminAPI.getAdminAgents(params) as any;
const data = result.data || result;
setAgents(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(`加载失败: ${e.message || e}`, 'error');
} finally {
setLoading(false);
}
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminAgentsStats() as any;
const data = result.data || result;
setStats(data);
} catch {}
};
// Load available skills & models
const loadAvailable = async () => {
try {
const [skillsResult, modelsResult] = await Promise.all([
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
adminAPI.getAdminAvailableModels() as any,
]);
const skills = (skillsResult.data || skillsResult).items || [];
const models = (modelsResult.data || modelsResult) || [];
setAvailableSkills(skills);
setAvailableModels(models);
} catch {}
};
useEffect(() => {
loadAgents();
}, [page, keyword, filterStatus, filterSource]);
useEffect(() => {
loadStats();
}, []);
// Search
const handleSearch = () => {
setPage(1);
loadAgents();
};
// Open create
const openCreate = async () => {
setEditingAgent(null);
await loadAvailable();
setForm({
name: '',
description: '',
icon: '',
price: '0',
is_member_free: false,
system_prompt: '你是一个智能助手。',
temperature: '0.7',
max_tokens: '2048',
model_id: '',
skills: [],
memory_enabled: false,
reasoning_enabled: false,
});
setShowModal(true);
};
// Open edit
const openEdit = async (agent: Agent) => {
setEditingAgent(agent);
await loadAvailable();
const cfg = agent.config || {};
setForm({
name: agent.name,
description: agent.description || '',
icon: agent.icon || '',
price: String(agent.price),
is_member_free: agent.is_member_free,
system_prompt: cfg.system_prompt || '',
temperature: String(cfg.temperature || 0.7),
max_tokens: String(cfg.max_tokens || 2048),
model_id: cfg.model_id || '',
skills: cfg.skills || [],
memory_enabled: cfg.memory_enabled || false,
reasoning_enabled: cfg.reasoning_enabled || false,
});
setShowModal(true);
};
// Open detail
const openDetail = async (agent: Agent) => {
setDetailAgent(agent);
setShowDetail(true);
};
// Save agent
const handleSave = async () => {
if (!form.name.trim()) {
showToast('Agent 名称不能为空', 'error');
return;
}
const payload: any = {
name: form.name,
description: form.description,
icon: form.icon || undefined,
price: parseFloat(form.price) || 0,
is_member_free: form.is_member_free,
config: {
system_prompt: form.system_prompt,
temperature: parseFloat(form.temperature) || 0.7,
max_tokens: parseInt(form.max_tokens) || 2048,
model_id: form.model_id || undefined,
skills: form.skills,
memory_enabled: form.memory_enabled,
reasoning_enabled: form.reasoning_enabled,
},
};
try {
if (editingAgent) {
await adminAPI.updateAdminAgent(editingAgent.id, payload);
showToast('Agent 已更新');
} else {
await adminAPI.createAdminAgent(payload);
showToast('Agent 创建成功');
}
setShowModal(false);
loadAgents();
loadStats();
} catch (e: any) {
showToast(`保存失败: ${e.message || e}`, 'error');
}
};
// Toggle agent
const handleToggle = async (agent: Agent) => {
const isActive = agent.status === 'published';
try {
await adminAPI.toggleAdminAgent(agent.id, !isActive);
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
loadAgents();
loadStats();
} catch (e: any) {
showToast(`操作失败: ${e.message || e}`, 'error');
}
};
// Delete agent
const handleDelete = async (agent: Agent) => {
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
try {
await adminAPI.deleteAdminAgent(agent.id);
showToast('Agent 已删除');
loadAgents();
loadStats();
} catch (e: any) {
showToast(`删除失败: ${e.message || e}`, 'error');
}
};
// Toggle skill in selection
const toggleSkill = (skillId: string) => {
setForm(f => ({
...f,
skills: f.skills.includes(skillId)
? f.skills.filter(s => s !== skillId)
: [...f.skills, skillId],
}));
};
const PAGE_SIZE = 15;
const totalPages = Math.ceil(total / PAGE_SIZE);
const statusColor = (status: string) => {
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
return { bg: '#FEE2E2', color: '#EF4444' };
};
return (
<div className="admin-page">
{/* Toast */}
{toast && (
<div className="toast" style={{
position: 'fixed', top: 20, right: 20, zIndex: 9999,
padding: '12px 20px', borderRadius: 8,
background: toast.type === 'success' ? '#10B981' : '#EF4444',
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="admin-page-header">
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Agent 管理</h2>
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
</p>
</div>
</div>
{/* Stats */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
<div className="stat-card" style={{ borderLeft: '3px solid #6366F1' }}>
<div className="stat-value">{stats.total_agents}</div>
<div className="stat-label">Agent 总数</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #10B981' }}>
<div className="stat-value">{stats.published_agents}</div>
<div className="stat-label">已发布</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #F59E0B' }}>
<div className="stat-value">{stats.pending_agents}</div>
<div className="stat-label">待发布</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
<div className="stat-value">{stats.total_installs.toLocaleString()}</div>
<div className="stat-label">总安装量</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
<div className="stat-value">{stats.average_rating.toFixed(1)}</div>
<div className="stat-label">平均评分</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #EC4899' }}>
<div className="stat-value">{stats.official_agents} / {stats.user_agents}</div>
<div className="stat-label">官方 / 用户</div>
</div>
</div>
)}
{/* Filters */}
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
<input className="admin-form-input" placeholder="搜索名称、描述..."
value={keyword} onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
style={{ maxWidth: 280 }} />
<select className="admin-form-input" value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
style={{ maxWidth: 140 }}>
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="pending">待发布</option>
<option value="removed">已下架</option>
</select>
<select className="admin-form-input" value={filterSource} onChange={e => { setFilterSource(e.target.value); setPage(1); }}
style={{ maxWidth: 140 }}>
<option value="">全部来源</option>
<option value="official">官方</option>
<option value="user">用户</option>
</select>
<button className="admin-btn" onClick={handleSearch}>搜索</button>
{(keyword || filterStatus || filterSource) && (
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterStatus(''); setFilterSource(''); setPage(1); }}>
清空
</button>
)}
</div>
{/* Table */}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>智能体</th>
<th>类型</th>
<th>来源</th>
<th>状态</th>
<th>价格</th>
<th>安装量</th>
<th>评分</th>
<th>更新时间</th>
<th style={{ width: 240 }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
) : agents.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无 Agent</td></tr>
) : agents.map(agent => {
const sc = statusColor(agent.status);
return (
<tr key={agent.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{agent.icon ? (
<img src={agent.icon} alt="" style={{ width: 32, height: 32, borderRadius: 8, objectFit: 'cover' }} />
) : (
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#EEF2FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
🤖
</div>
)}
<div>
<div style={{ fontWeight: 500 }}>{agent.name}</div>
{agent.description && (
<div style={{ fontSize: 12, color: '#6B7280', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{agent.description}
</div>
)}
</div>
</div>
</td>
<td>
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 10, background: '#EEF2FF', color: '#6366F1' }}>
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
</span>
</td>
<td>
<span style={{ fontSize: 12, color: agent.source === 'official' ? '#10B981' : '#6B7280' }}>
{agent.source === 'official' ? '官方' : '用户'}
</span>
</td>
<td>
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12, background: sc.bg, color: sc.color, fontWeight: 500 }}>
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
</span>
</td>
<td>{agent.price === 0 ? <span style={{ color: '#10B981' }}>免费</span> : `¥${agent.price}`}</td>
<td>{agent.install_count.toLocaleString()}</td>
<td>{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'}</td>
<td style={{ fontSize: 12, color: '#6B7280' }}>
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
</td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="admin-btn small" onClick={() => openDetail(agent)}>详情</button>
<button className="admin-btn small" onClick={() => openEdit(agent)}>编辑</button>
<button
className={`admin-btn small ${agent.status === 'published' ? 'warning' : 'success'}`}
onClick={() => handleToggle(agent)}
>
{agent.status === 'published' ? '禁用' : '启用'}
</button>
<button className="admin-btn small danger" onClick={() => handleDelete(agent)}>删除</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="admin-pagination">
<span style={{ color: '#6B7280', fontSize: 13 }}>共 {total} 条,第 {page}/{totalPages} 页</span>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
return p <= totalPages && (
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`} onClick={() => setPage(p)}>{p}</button>
);
})}
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editingAgent ? '编辑 Agent' : '创建 Agent'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">名称 *</label>
<input className="admin-form-input" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="例如: 智能代码审查助手" />
</div>
<div className="form-group">
<label className="form-label">图标 URL</label>
<input className="admin-form-input" value={form.icon}
onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
placeholder="https://..." />
</div>
<div className="form-group">
<label className="form-label">价格(元,0=免费)</label>
<input className="admin-form-input" type="number" value={form.price}
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={form.is_member_free}
onChange={e => setForm(f => ({ ...f, is_member_free: e.target.checked }))} />
会员免费
</label>
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">描述</label>
<textarea className="admin-form-input" rows={2} value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="描述 Agent 的功能和使用场景..." />
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
<div style={{ fontWeight: 600, marginBottom: 12 }}>⚙️ Agent 配置</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">系统提示词</label>
<textarea className="admin-form-input"
value={form.system_prompt}
onChange={e => setForm(f => ({ ...f, system_prompt: e.target.value }))}
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 120 }}
placeholder="定义 Agent 的角色、行为和能力..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">模型</label>
<select className="admin-form-input" value={form.model_id}
onChange={e => setForm(f => ({ ...f, model_id: e.target.value }))}>
<option value="">默认模型</option>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.provider})</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">温度参数</label>
<input className="admin-form-input" type="number" step="0.1" min="0" max="2"
value={form.temperature}
onChange={e => setForm(f => ({ ...f, temperature: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">最大令牌数</label>
<input className="admin-form-input" type="number"
value={form.max_tokens}
onChange={e => setForm(f => ({ ...f, max_tokens: e.target.value }))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={form.memory_enabled}
onChange={e => setForm(f => ({ ...f, memory_enabled: e.target.checked }))} />
记忆能力
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={form.reasoning_enabled}
onChange={e => setForm(f => ({ ...f, reasoning_enabled: e.target.checked }))} />
推理能力
</label>
</div>
{/* Linked Skills */}
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">关联技能(可多选)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableSkills.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
) : availableSkills.map(skill => (
<label key={skill.id} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
borderRadius: 12, fontSize: 12, cursor: 'pointer',
background: form.skills.includes(skill.id) ? '#EEF2FF' : '#F9FAFB',
border: `1px solid ${form.skills.includes(skill.id) ? '#6366F1' : '#E5E7EB'}`,
color: form.skills.includes(skill.id) ? '#6366F1' : '#374151',
}}>
<input type="checkbox" style={{ display: 'none' }}
checked={form.skills.includes(skill.id)}
onChange={() => toggleSkill(skill.id)} />
{skill.skill_name}
{skill.state !== 'baby' && (
<span style={{ fontSize: 10, background: '#E5E7EB', padding: '1px 4px', borderRadius: 4, marginLeft: 4 }}>
{skill.state}
</span>
)}
</label>
))}
</div>
{form.skills.length > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: '#6366F1' }}>
已选 {form.skills.length} 个技能
</div>
)}
</div>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
<button className="admin-btn primary" onClick={handleSave}>
{editingAgent ? '保存更改' : '创建 Agent'}
</button>
</div>
</div>
</div>
)}
{/* Detail Drawer */}
{showDetail && detailAgent && (
<div className="modal-overlay" onClick={() => setShowDetail(false)}>
<div className="modal-content" style={{ maxWidth: 600 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{detailAgent.name}</h3>
<button className="modal-close" onClick={() => setShowDetail(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div className="detail-item"><span className="detail-label">类型</span><span>{detailAgent.type}</span></div>
<div className="detail-item"><span className="detail-label">来源</span><span>{detailAgent.source === 'official' ? '官方' : '用户'}</span></div>
<div className="detail-item"><span className="detail-label">状态</span><span>{detailAgent.status}</span></div>
<div className="detail-item"><span className="detail-label">价格</span><span>¥{detailAgent.price}</span></div>
<div className="detail-item"><span className="detail-label">安装量</span><span>{detailAgent.install_count}</span></div>
<div className="detail-item"><span className="detail-label">评分</span><span>{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}</span></div>
<div className="detail-item"><span className="detail-label">发布者</span><span>{detailAgent.publisher_name || detailAgent.publisher_id}</span></div>
<div className="detail-item"><span className="detail-label">创建时间</span><span>{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}</span></div>
</div>
{detailAgent.description && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>描述</div>
<p style={{ color: '#6B7280', fontSize: 13, margin: 0 }}>{detailAgent.description}</p>
</div>
)}
{detailAgent.config && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}>⚙️ 配置</div>
<div style={{ background: '#F9FAFB', borderRadius: 8, padding: 12, fontSize: 13 }}>
{detailAgent.config.system_prompt && (
<div style={{ marginBottom: 8 }}>
<span style={{ color: '#6B7280' }}>System Prompt:</span>
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
{detailAgent.config.system_prompt}
</pre>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
<span>Temperature: <code>{detailAgent.config.temperature}</code></span>
<span>Max Tokens: <code>{detailAgent.config.max_tokens}</code></span>
<span>Model: <code>{detailAgent.config.model_id || '默认'}</code></span>
<span>记忆: <code>{detailAgent.config.memory_enabled ? '✓' : '✗'}</code></span>
<span>推理: <code>{detailAgent.config.reasoning_enabled ? '✓' : '✗'}</code></span>
<span>关联技能: <code>{detailAgent.config.skills?.length || 0} 个</code></span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
<style>{`
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { margin: 0; font-size: 18px; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
.modal-body { padding: 24px; overflow-y: auto; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
.detail-item { display: flex; gap: 8px; font-size: 13px; }
.detail-label { color: #6B7280; min-width: 60px; }
`}</style>
</div>
);
};
export default Agents;
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Overview {
total_users: number; active_users: number; new_users: number;
api_calls: number; avg_response_time: number; error_rate: number;
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
}
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
const Analytics: React.FC = () => {
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(false);
const [overview, setOverview] = useState<Overview | null>(null);
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const [ov, api, tok, usr, rev, mu] = await Promise.all([
adminAPI.getAnalyticsOverview(days),
adminAPI.getAnalyticsApiTrends(days),
adminAPI.getAnalyticsTokenTrends(days),
adminAPI.getAnalyticsUserTrends(days),
adminAPI.getAnalyticsRevenueTrends(days),
adminAPI.getAnalyticsModelUsage(days),
]);
setOverview((ov as any)?.data || ov);
setApiTrend((api as any)?.data || []);
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
setTokenTrend(td);
setUserTrend((usr as any)?.data || []);
setRevenueTrend((rev as any)?.data || []);
setModelUsage((mu as any)?.data || []);
} catch (e) { console.error('fetchAnalytics:', e); }
setLoading(false);
}, [days]);
useEffect(() => { fetchAll(); }, [fetchAll]);
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
const values = data.map((d: any) => d[key] || 0);
const max = Math.max(...values, 1);
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
{data.map((d: any, i) => {
const v = d[key] || 0;
const h = (v / max) * 140;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
<div style={{ fontSize: '10px', color: '#8892B0', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
</div>
);
})}
</div>
);
};
const renderLine = (data: TrendItem[], key: string, color: string) => {
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
const values = data.map((d: any) => d[key] || 0);
const max = Math.max(...values, 1);
const w = 100 / (data.length - 1 || 1);
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
return (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
{data.map((d: any, i) => (
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
))}
</svg>
);
};
const statCards = overview ? [
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
] : [];
return (
<div style={{ padding: '24px', color: '#1A1F36' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
))}
</div>
</div>
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
<>
{/* Stats grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
{statCards.map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
<div>
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
</div>
</div>
))}
</div>
{/* Charts grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
</div>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
</div>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
</div>
</div>
{/* Model usage */}
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
{modelUsage.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{modelUsage.map((m, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#8892B0' }}>{m.avg_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
</div>
{/* Model distribution pie (CSS) */}
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
{(() => {
const entries = Object.entries(tokenTrend.model_distribution);
const total = entries.reduce((s, [, v]) => s + v, 1);
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
return entries.map(([model, val], i) => {
const pct = (val / total) * 100;
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
});
})()}
</div>
<div style={{ flex: 1 }}>
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
return (
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
</div>
);
})}
</div>
</div>
</div>
)}
</>
)}
</div>
);
};
export default Analytics;
+519
View File
@@ -0,0 +1,519 @@
.admin-apps-page {
padding: 24px;
}
.admin-apps-page .admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.admin-apps-page .admin-page-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--color-text-tertiary);
margin-top: 4px;
}
/* Tabs */
.tabs-bar {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover {
color: var(--color-text-primary);
}
.tab-btn.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
font-weight: 500;
}
.tab-badge {
background: var(--color-accent);
color: white;
font-size: 11px;
padding: 1px 7px;
border-radius: 10px;
font-weight: 500;
}
.tab-btn:not(.active) .tab-badge {
background: var(--color-text-disabled);
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-input, .filter-select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
outline: none;
transition: border-color 0.2s;
}
.filter-input {
flex: 1;
min-width: 200px;
}
.filter-input:focus, .filter-select:focus {
border-color: var(--color-accent);
}
/* Table */
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-border);
}
.admin-table th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 500;
color: var(--color-text-tertiary);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.admin-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
font-size: 14px;
color: var(--color-text-primary);
}
.admin-table tr:hover {
background: var(--color-bg-secondary);
}
.admin-table tr:last-child td {
border-bottom: none;
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
}
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
/* Buttons */
.btn-primary, .btn-secondary, .btn-sm, .btn-danger, .btn-success, .btn-block {
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
padding: 8px 16px;
background: var(--color-accent);
color: white;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
padding: 8px 16px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover { background: var(--color-border); }
.btn-sm {
padding: 4px 10px;
font-size: 12px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-sm:hover { background: var(--color-border); }
.btn-success {
padding: 4px 10px;
font-size: 12px;
background: #10b981;
color: white;
}
.btn-success:hover { opacity: 0.9; }
.btn-danger {
padding: 4px 10px;
font-size: 12px;
background: #ef4444;
color: white;
}
.btn-danger:hover { opacity: 0.9; }
.btn-block {
width: 100%;
padding: 8px 16px;
background: var(--color-accent);
color: white;
margin-top: 8px;
}
.btn-block:hover { opacity: 0.9; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 20px;
font-size: 14px;
color: var(--color-text-secondary);
}
.pagination button {
padding: 6px 14px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
cursor: pointer;
font-size: 14px;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Templates Grid */
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.template-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow 0.2s;
}
.template-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.template-icon {
font-size: 32px;
margin-bottom: 4px;
}
.template-name {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.template-desc {
font-size: 13px;
color: var(--color-text-tertiary);
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.template-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Analytics */
.analytics-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
text-align: center;
min-width: 120px;
}
.analytics-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary);
}
.analytics-label {
font-size: 13px;
color: var(--color-text-tertiary);
margin-top: 4px;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: 12px;
width: 90%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.modal-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-tertiary);
padding: 0;
line-height: 1;
}
.modal-close:hover { color: var(--color-text-primary); }
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--color-border);
}
/* Form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
outline: none;
transition: border-color 0.2s;
font-family: inherit;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--color-accent);
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 20px 0 12px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
/* Config Section */
.config-section {
background: var(--color-bg-secondary);
border-radius: 8px;
padding: 16px;
}
.config-item {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: 13px;
color: var(--color-text-tertiary);
min-width: 100px;
flex-shrink: 0;
}
.config-value {
font-size: 13px;
color: var(--color-text-primary);
word-break: break-word;
}
/* Version List */
.version-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.version-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-bg-secondary);
border-radius: 8px;
font-size: 13px;
}
.version-num {
font-weight: 600;
color: var(--color-accent);
min-width: 40px;
}
.version-log {
flex: 1;
color: var(--color-text-primary);
}
.version-date {
color: var(--color-text-tertiary);
font-size: 12px;
}
+799
View File
@@ -0,0 +1,799 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import './Apps.css';
interface AIApp {
id: string;
name: string;
description: string | null;
type: string;
category: string;
tags: string | null;
icon: string | null;
status: string;
version: number;
is_template: boolean;
is_public: boolean;
config: any;
created_at: string | null;
updated_at: string | null;
published_at: string | null;
analytics_7d?: any;
versions?: any[];
}
interface AppStats {
total: number;
published: number;
draft: number;
paused: number;
archived: number;
templates: number;
by_type: Record<string, number>;
by_category: Record<string, number>;
recent_7d: number;
total_versions: number;
}
interface Template {
id: string;
name: string;
description: string | null;
type: string;
category: string;
icon: string | null;
config: any;
created_at: string | null;
}
const CATEGORY_NAMES: Record<string, string> = {
customer_service: '客服助手',
data_analysis: '数据分析',
coding: '编程辅助',
writing: '内容创作',
education: '教育培训',
marketing: '营销推广',
hr: '人力资源',
finance: '财务法务',
custom: '自定义',
};
const TYPE_NAMES: Record<string, string> = {
chatbot: '聊天机器人',
assistant: 'AI助手',
workflow: '工作流',
api_service: 'API服务',
};
const STATUS_COLORS: Record<string, string> = {
draft: '#f59e0b',
published: '#10b981',
paused: '#6b7280',
archived: '#9ca3af',
};
const STATUS_NAMES: Record<string, string> = {
draft: '草稿',
published: '已发布',
paused: '已暂停',
archived: '已归档',
};
export default function AdminApps() {
const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps');
const [stats, setStats] = useState<AppStats | null>(null);
const [apps, setApps] = useState<AIApp[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editApp, setEditApp] = useState<AIApp | null>(null);
const [detailApp, setDetailApp] = useState<AIApp | null>(null);
const [detailAnalytics, setDetailAnalytics] = useState<any>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getAIAppStats();
if (res.code === 0) setStats(res.data);
} catch (e) { console.error(e); }
}, []);
const fetchApps = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', '20');
if (search) params.set('search', search);
if (filterType) params.set('app_type', filterType);
if (filterStatus) params.set('status', filterStatus);
if (filterCategory) params.set('category', filterCategory);
const res = await adminAPI.getAIApps(params.toString());
if (res.code === 0) {
setApps(res.data.items || []);
setTotal(res.data.total || 0);
setTotalPages(res.data.total_pages || 1);
}
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [page, search, filterType, filterStatus, filterCategory]);
const fetchTemplates = useCallback(async () => {
try {
const res = await adminAPI.getAIAppTemplates();
if (res.code === 0) setTemplates(res.data.items || []);
} catch (e) { console.error(e); }
}, []);
const fetchDetail = useCallback(async (id: string) => {
try {
const res = await adminAPI.getAIApp(id);
if (res.code === 0) {
setDetailApp(res.data);
// Fetch analytics
try {
const aRes = await adminAPI.getAIAppAnalytics(id);
if (aRes.code === 0) setDetailAnalytics(aRes.data);
} catch (e) { /* analytics may be empty */ }
}
} catch (e) { console.error(e); }
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
useEffect(() => {
if (activeTab === 'apps') fetchApps();
if (activeTab === 'templates') fetchTemplates();
}, [activeTab, fetchApps, fetchTemplates]);
const handleCreate = async (data: any) => {
try {
const res = await adminAPI.createAIApp(data);
if (res.code === 0) {
setShowCreateModal(false);
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleUpdate = async (data: any) => {
if (!editApp) return;
try {
const res = await adminAPI.updateAIApp(editApp.id, data);
if (res.code === 0) {
setEditApp(null);
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除此应用吗?此操作不可逆。')) return;
try {
const res = await adminAPI.deleteAIApp(id);
if (res.code === 0) {
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleLifecycle = async (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => {
try {
const res = await adminAPI.aiAppLifecycle(id, action);
if (res.code === 0) {
fetchApps();
fetchStats();
if (detailApp?.id === id) fetchDetail(id);
}
} catch (e) { console.error(e); }
};
const handleCreateTemplate = async (data: any) => {
try {
const res = await adminAPI.createAIAppTemplate(data);
if (res.code === 0) {
setShowTemplateModal(false);
fetchTemplates();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleFromTemplate = async (templateId: string, name: string) => {
try {
const res = await adminAPI.createAIAppFromTemplate(templateId, { name });
if (res.code === 0) {
setActiveTab('apps');
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const formatDate = (dt: string | null) => {
if (!dt) return '-';
try {
const d = new Date(dt);
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} catch { return dt; }
};
return (
<div className="admin-apps-page">
<div className="admin-page-header">
<h1>AI应用管理</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{activeTab === 'apps' && (
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>+ 创建应用</button>
)}
{activeTab === 'templates' && (
<button className="btn-primary" onClick={() => setShowTemplateModal(true)}>+ 创建模板</button>
)}
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">应用总数</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10b981' }}>{stats.published}</div>
<div className="stat-label">已发布</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#f59e0b' }}>{stats.draft}</div>
<div className="stat-label">草稿</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.templates}</div>
<div className="stat-label">应用模板</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3b82f6' }}>{stats.recent_7d}</div>
<div className="stat-label">近7天新增</div>
</div>
</div>
)}
{/* Tabs */}
<div className="tabs-bar">
{([
{ key: 'apps' as const, label: '应用列表', count: stats?.total },
{ key: 'templates' as const, label: '应用模板', count: stats?.templates },
{ key: 'analytics' as const, label: '分析概览', count: undefined as number | undefined },
]).map(tab => (
<button
key={tab.key}
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className="tab-badge">{tab.count}</span>
)} </button>
))}
</div>
{/* Apps Tab */}
{activeTab === 'apps' && (
<div>
{/* Filters */}
<div className="filter-bar">
<input
type="text"
placeholder="搜索应用名称..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="filter-input"
/>
<select value={filterType} onChange={e => { setFilterType(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部类型</option>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部状态</option>
{Object.entries(STATUS_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部分类</option>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{/* Table */}
<table className="admin-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>分类</th>
<th>状态</th>
<th>版本</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>加载中...</td></tr>
) : apps.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)' }}>暂无应用</td></tr>
) : apps.map(app => (
<tr key={app.id} style={{ cursor: 'pointer' }} onClick={() => fetchDetail(app.id)}>
<td>
<div style={{ fontWeight: 500 }}>{app.name}</div>
{app.description && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{app.description}</div>}
</td>
<td><span className="badge">{TYPE_NAMES[app.type] || app.type}</span></td>
<td>{CATEGORY_NAMES[app.category] || app.category}</td>
<td>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
</td>
<td>v{app.version}</td>
<td style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{formatDate(app.created_at)}</td>
<td onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<button className="btn-sm" onClick={() => setEditApp(app)}>编辑</button>
{app.status === 'draft' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-sm" onClick={() => handleLifecycle(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>恢复</button>}
<button className="btn-sm" onClick={() => handleLifecycle(app.id, 'duplicate')}>复制</button>
<button className="btn-sm btn-danger" onClick={() => handleDelete(app.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span>第 {page} / {totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="templates-grid">
{templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
暂无模板,点击右上角"创建模板"添加
</div>
) : templates.map(tpl => (
<div key={tpl.id} className="template-card">
<div className="template-icon">{tpl.icon || '📦'}</div>
<div className="template-name">{tpl.name}</div>
<div className="template-desc">{tpl.description || '暂无描述'}</div>
<div className="template-meta">
<span className="badge">{TYPE_NAMES[tpl.type] || tpl.type}</span>
<span className="badge">{CATEGORY_NAMES[tpl.category] || tpl.category}</span>
</div>
<button className="btn-primary btn-block" onClick={() => {
const name = prompt('请输入应用名称', tpl.name + ' (Instance)');
if (name) handleFromTemplate(tpl.id, name);
}}>从模板创建</button>
</div>
))}
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && stats && (
<div>
<h3 style={{ marginBottom: '16px' }}>应用类型分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '32px' }}>
{Object.entries(stats.by_type).map(([type, count]) => (
<div key={type} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{TYPE_NAMES[type] || type}</div>
</div>
))}
{Object.keys(stats.by_type).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginBottom: '16px' }}>分类分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{Object.entries(stats.by_category).map(([cat, count]) => (
<div key={cat} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{CATEGORY_NAMES[cat] || cat}</div>
</div>
))}
{Object.keys(stats.by_category).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginTop: '32px', marginBottom: '16px' }}>版本统计</h3>
<div className="analytics-card" style={{ display: 'inline-block' }}>
<div className="analytics-value">{stats.total_versions}</div>
<div className="analytics-label">总版本数</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{(showCreateModal || editApp) && (
<AppFormModal
app={editApp}
onClose={() => { setShowCreateModal(false); setEditApp(null); }}
onSubmit={editApp ? handleUpdate : handleCreate}
/>
)}
{/* Template Modal */}
{showTemplateModal && (
<TemplateFormModal
onClose={() => setShowTemplateModal(false)}
onSubmit={handleCreateTemplate}
/>
)}
{/* Detail Drawer */}
{detailApp && (
<AppDetailDrawer
app={detailApp}
analytics={detailAnalytics}
onClose={() => { setDetailApp(null); setDetailAnalytics(null); }}
onAction={handleLifecycle}
/>
)}
</div>
);
}
// ============================================================
// App Form Modal
// ============================================================
function AppFormModal({ app, onClose, onSubmit }: {
app: AIApp | null;
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState(app?.name || '');
const [description, setDescription] = useState(app?.description || '');
const [type, setType] = useState(app?.type || 'chatbot');
const [category, setCategory] = useState(app?.category || 'custom');
const [tags, setTags] = useState(app?.tags || '');
const [systemPrompt, setSystemPrompt] = useState(app?.config?.system_prompt || '');
const [temperature, setTemperature] = useState(app?.config?.temperature ?? 0.7);
const [maxTokens, setMaxTokens] = useState(app?.config?.max_tokens ?? 2000);
const [welcomeMessage, setWelcomeMessage] = useState(app?.config?.welcome_message || '');
const handleSubmit = () => {
if (!name.trim()) { alert('请输入应用名称'); return; }
onSubmit({
name, description, type, category, tags,
config: {
...(app?.config || {}),
system_prompt: systemPrompt,
temperature: Number(temperature),
max_tokens: Number(maxTokens),
welcome_message: welcomeMessage,
}
});
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app ? '编辑应用' : '创建应用'}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>应用名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="输入应用名称" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="应用描述" rows={2} />
</div>
<div className="form-group">
<label>标签(逗号分隔)</label>
<input type="text" value={tags} onChange={e => setTags(e.target.value)} placeholder="如:客服,电商,自动回复" />
</div>
<div className="form-section-title">AI配置</div>
<div className="form-group">
<label>系统提示词 (System Prompt)</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="定义AI的角色和行为..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度 (Temperature): {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min="100" max="32000" />
</div>
</div>
<div className="form-group">
<label>欢迎消息</label>
<input type="text" value={welcomeMessage} onChange={e => setWelcomeMessage(e.target.value)} placeholder="用户打开应用时显示的欢迎语" />
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>{app ? '保存' : '创建'}</button>
</div>
</div>
</div>
);
}
// ============================================================
// Template Form Modal
// ============================================================
function TemplateFormModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState('chatbot');
const [category, setCategory] = useState('custom');
const [systemPrompt, setSystemPrompt] = useState('');
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(2000);
const handleSubmit = () => {
if (!name.trim()) { alert('请输入模板名称'); return; }
onSubmit({
name, description, type, category,
config: {
system_prompt: systemPrompt,
temperature: Number(temperature),
max_tokens: Number(maxTokens),
skill_ids: [],
knowledge_base_ids: [],
tools: [],
welcome_message: ''
}
});
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>创建应用模板</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>模板名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="如:电商客服机器人" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="模板描述" rows={2} />
</div>
<div className="form-group">
<label>系统提示词</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="预置的系统提示词..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度: {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>创建模板</button>
</div>
</div>
</div>
);
}
// ============================================================
// App Detail Drawer
// ============================================================
function AppDetailDrawer({ app, analytics, onClose, onAction }: {
app: AIApp;
analytics: any;
onClose: () => void;
onAction: (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => void;
}) {
const a7 = app.analytics_7d as any;
const summary = analytics?.summary;
return (
<div className="modal-overlay" onClick={onClose} style={{ justifyContent: 'flex-end' }}>
<div className="modal-content" style={{ maxWidth: '560px', height: '100vh', borderRadius: 0 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app.name}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body" style={{ overflow: 'auto' }}>
{/* Status & Meta */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
<span className="badge">{TYPE_NAMES[app.type] || app.type}</span>
<span className="badge">{CATEGORY_NAMES[app.category] || app.category}</span>
<span className="badge">v{app.version}</span>
</div>
{app.description && <p style={{ color: 'var(--color-text-secondary)', marginBottom: '16px' }}>{app.description}</p>}
{/* Quick Stats */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: '24px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.conversations || 0}</div>
<div className="stat-label">7天对话</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.tokens || 0}</div>
<div className="stat-label">7天Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.errors || 0}</div>
<div className="stat-label">7天错误</div>
</div>
</div>
{/* Config */}
<h3 style={{ marginBottom: '12px' }}>应用配置</h3>
<div className="config-section">
{app.config?.system_prompt && (
<div className="config-item">
<span className="config-label">系统提示词</span>
<div className="config-value">{app.config.system_prompt}</div>
</div>
)}
<div className="config-item">
<span className="config-label">温度</span>
<div className="config-value">{app.config?.temperature ?? 'N/A'}</div>
</div>
<div className="config-item">
<span className="config-label">最大Token</span>
<div className="config-value">{app.config?.max_tokens ?? 'N/A'}</div>
</div>
{app.config?.skill_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定技能</span>
<div className="config-value">{app.config.skill_ids.length} 个技能</div>
</div>
)}
{app.config?.knowledge_base_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定知识库</span>
<div className="config-value">{app.config.knowledge_base_ids.length} 个知识库</div>
</div>
)}
</div>
{/* Versions */}
{app.versions && app.versions.length > 0 && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>版本历史</h3>
<div className="version-list">
{app.versions.map((v: any) => (
<div key={v.id} className="version-item">
<span className="version-num">v{v.version}</span>
<span className="version-log">{v.changelog}</span>
<span className="version-date">{v.created_at ? new Date(v.created_at).toLocaleDateString('zh-CN') : '-'}</span>
</div>
))}
</div>
</>
)}
{/* Analytics Detail */}
{summary && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>分析数据 ({analytics.days}天)</h3>
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)', marginBottom: '16px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.conversations}</div>
<div className="stat-label">总对话数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.messages}</div>
<div className="stat-label">总消息数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.tokens_input + summary.tokens_output}</div>
<div className="stat-label">总Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.active_users}</div>
<div className="stat-label">活跃用户</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.avg_response_ms}ms</div>
<div className="stat-label">平均响应</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.error_rate}%</div>
<div className="stat-label">错误率</div>
</div>
</div>
</>
)}
{/* Timestamps */}
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
</div>
</div>
{/* Actions */}
<div className="modal-footer" style={{ justifyContent: 'flex-start' }}>
{app.status === 'draft' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-secondary" onClick={() => onAction(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>恢复发布</button>}
<button className="btn-secondary" onClick={() => onAction(app.id, 'duplicate')}>复制应用</button>
<button className="btn-secondary" onClick={() => onAction(app.id, 'archive')}>归档</button>
</div>
</div>
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ============================================================
// Types
// ============================================================
interface AssetItem {
id: string;
user_id: string;
name: string;
category: string;
file_type: string | null;
size: number;
size_formatted: string;
description: string | null;
url: string | null;
cover_url: string | null;
status: string;
username: string;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
}
interface StatsData {
total_assets: number;
total_size: number;
total_size_formatted: string;
by_category: Array<{ category: string; label: string; count: number; size: number; size_formatted: string }>;
by_file_type: Array<{ file_type: string; count: number; size: number; size_formatted: string }>;
top_users: Array<{ user_id: string; username: string; avatar: string | null; asset_count: number; total_size: number; total_size_formatted: string }>;
quota_summary: {
total_users: number;
total_quota: number;
total_used: number;
total_quota_formatted: string;
total_used_formatted: string;
usage_percent: number;
};
trend_7d: Array<{ date: string; new_uploads: number }>;
}
interface ActivityItem {
id: string;
name: string;
category: string;
category_label: string;
file_type: string | null;
size: number;
size_formatted: string;
status: string;
username: string;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
}
const CAT_LABELS: Record<string, string> = {
file: '文件', novel: '小说', image: '图片', video: '视频', audio: '音频', custom: '自定义', doc: '文档',
};
const CAT_COLORS: Record<string, string> = {
file: '#3B82F6', novel: '#8B5CF6', image: '#10B981', video: '#F59E0B', audio: '#EF4444', custom: '#6B7280', doc: '#06B6D4',
};
// ============================================================
// Icon
// ============================================================
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
const paths: Record<string, string> = {
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
activity: 'M22 12h-4l-3 9L9 3l-3 9H2', trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
user: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
database: 'M12 2C6.48 2 2 3.79 2 6s4.48 4 10 4 10-1.79 10-4-4.48-4-10-4zM2 6v6c0 2.21 4.48 4 10 4s10-1.79 10-4V6M2 12v6c0 2.21 4.48 4 10 4s10-1.79 10-4v-6',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<path d={paths[name] || ''} />
</svg>
);
};
// ============================================================
// Main Component
// ============================================================
const AssetCenter: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'assets' | 'activity'>('overview');
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<StatsData | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [assetsTotal, setAssetsTotal] = useState(0);
const [assetsPage, setAssetsPage] = useState(1);
const [activities, setActivities] = useState<ActivityItem[]>([]);
// Filters
const [filterCategory, setFilterCategory] = useState('');
const [filterFileType, setFilterFileType] = useState('');
const [filterKeyword, setFilterKeyword] = useState('');
const loadStats = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getAssetStats();
if (res?.code === 0) setStats(res.data as any);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadAssets = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterCategory) params.set('category', filterCategory);
if (filterFileType) params.set('file_type', filterFileType);
if (filterKeyword) params.set('keyword', filterKeyword);
params.set('page', String(assetsPage));
params.set('page_size', '20');
const res = await adminAPI.getAdminAssets(params.toString());
if (res?.code === 0) {
const d = res.data as any;
setAssets(d?.items || []);
setAssetsTotal(d?.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [filterCategory, filterFileType, filterKeyword, assetsPage]);
const loadActivities = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getAssetActivity();
if (res?.code === 0) setActivities(res.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => {
if (activeTab === 'overview') loadStats();
else if (activeTab === 'assets') loadAssets();
else if (activeTab === 'activity') loadActivities();
}, [activeTab, loadStats, loadAssets, loadActivities]);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除资产「${name}」?`)) return;
try {
await adminAPI.deleteAdminAsset(id);
loadAssets();
loadStats();
} catch (e) { alert('删除失败'); }
};
const tabs = [
{ key: 'overview', label: '总览', icon: 'chart' },
{ key: 'assets', label: '资产管理', icon: 'folder' },
{ key: 'activity', label: '活动记录', icon: 'activity' },
] as const;
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>资产中心</h1>
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>管理用户资产、存储分析、活动追踪</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)' }}>
{tabs.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
padding: '10px 20px', border: 'none',
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
borderRadius: '8px 8px 0 0', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: '6px', transition: 'all 0.2s',
}}>
<Icon name={tab.icon} size={16} /> {tab.label}
</button>
))}
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
<span>加载中...</span>
</div>
)}
{/* Overview */}
{!loading && activeTab === 'overview' && stats && <OverviewTab stats={stats} />}
{/* Assets */}
{!loading && activeTab === 'assets' && (
<AssetsTab
assets={assets} total={assetsTotal} page={assetsPage} onPageChange={setAssetsPage}
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
filterFileType={filterFileType} setFilterFileType={setFilterFileType}
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
onDelete={handleDelete}
/>
)}
{/* Activity */}
{!loading && activeTab === 'activity' && <ActivityTab activities={activities} />}
</div>
);
};
// ============================================================
// Overview Tab
// ============================================================
const OverviewTab: React.FC<{ stats: StatsData }> = ({ stats }) => {
const maxTrend = Math.max(...stats.trend_7d.map(t => t.new_uploads), 1);
return (
<div>
{/* Stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总资产数', value: stats.total_assets, color: 'var(--color-primary)' },
{ label: '总存储量', value: stats.total_size_formatted, color: 'var(--color-success)' },
{ label: '配额使用', value: `${stats.quota_summary.usage_percent}%`, sub: `${stats.quota_summary.total_used_formatted} / ${stats.quota_summary.total_quota_formatted}`, color: 'var(--color-warning)' },
{ label: '配额用户', value: stats.quota_summary.total_users, color: 'var(--color-danger)' },
].map((s, i) => (
<div key={i} style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
{s.sub && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>}
</div>
))}
</div>
{/* 7-day trend + Category distribution */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '24px' }}>
{/* Trend */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 20px' }}>7日上传趋势</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '140px' }}>
{stats.trend_7d.map((t, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '24px', height: `${(t.new_uploads / maxTrend) * 110 + 4}px`,
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0', minHeight: '4px',
}} title={`${t.new_uploads} uploads`} />
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{t.date.slice(5)}</span>
</div>
))}
</div>
</div>
{/* Category distribution */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
{stats.by_category.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.by_category.map(cat => (
<div key={cat.category} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '2px', background: CAT_COLORS[cat.category] || '#6B7280' }} />
{cat.label} ({cat.count})
</span>
<span style={{ fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{cat.size_formatted}</span>
</div>
<div style={{ height: '6px', background: 'var(--color-bg-tertiary)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{
height: '100%', width: `${stats.total_assets > 0 ? (cat.count / stats.total_assets * 100) : 0}%`,
background: CAT_COLORS[cat.category] || '#6B7280', borderRadius: '3px', transition: 'width 0.3s',
}} />
</div>
</div>
))
)}
</div>
</div>
{/* Top users + File types */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '16px' }}>
{/* Top users */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>存储量 Top 10 用户</h3>
{stats.top_users.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.top_users.map((u, i) => (
<div key={u.user_id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>#{i + 1}</span>
{u.avatar ? <img src={u.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>{u.username?.charAt(0) || 'U'}</div>}
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500 }}>{u.username}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{u.asset_count} 个资产</div>
</div>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{u.total_size_formatted}</span>
</div>
))
)}
</div>
{/* File types */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>文件类型</h3>
{stats.by_file_type.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.by_file_type.map(ft => (
<div key={ft.file_type} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '13px', fontFamily: 'monospace' }}>{ft.file_type}</span>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{ft.count}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{ft.size_formatted}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
);
};
// ============================================================
// Assets Tab
// ============================================================
const AssetsTab: React.FC<{
assets: AssetItem[]; total: number; page: number; onPageChange: (p: number) => void;
filterCategory: string; setFilterCategory: (v: string) => void;
filterFileType: string; setFilterFileType: (v: string) => void;
filterKeyword: string; setFilterKeyword: (v: string) => void;
onDelete: (id: string, name: string) => void;
}> = ({ assets, total, page, onPageChange, filterCategory, setFilterCategory, filterFileType, setFilterFileType, filterKeyword, setFilterKeyword, onDelete }) => {
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Filters */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<input placeholder="搜索资产..." value={filterKeyword} onChange={e => setFilterKeyword(e.target.value)} style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部分类</option>
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterFileType} onChange={e => setFilterFileType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部类型</option>
<option value="pdf">PDF</option>
<option value="docx">Word</option>
<option value="xlsx">Excel</option>
<option value="pptx">PPT</option>
<option value="txt">TXT</option>
<option value="md">Markdown</option>
<option value="csv">CSV</option>
</select>
</div>
{/* Table */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['资产名称', '用户', '分类', '类型', '大小', '创建时间', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{assets.map(a => (
<tr key={a.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{a.cover_url ? <img src={a.cover_url} alt="" style={{ width: '28px', height: '28px', borderRadius: '4px' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '4px', background: CAT_COLORS[a.category] || '#6B7280', opacity: 0.2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Icon name="folder" size={14} color={CAT_COLORS[a.category] || '#6B7280'} /></div>}
<span style={{ fontSize: '13px', fontWeight: 500 }}>{a.name}</span>
</div>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '20px', height: '20px', borderRadius: '50%' }} /> : null}
{a.username}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '4px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{CAT_LABELS[a.category] || a.category}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace' }}>{a.file_type || '—'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{a.size_formatted}</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{a.created_at?.slice(0, 16) || '—'}</td>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => onDelete(a.id, a.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
{assets.length === 0 && <div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无资产</div>}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
</div>
)}
</div>
);
};
// ============================================================
// Activity Tab
// ============================================================
const ActivityTab: React.FC<{ activities: ActivityItem[] }> = ({ activities }) => (
<div>
{activities.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="activity" size={48} color="var(--color-text-tertiary)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无活动记录</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{activities.map(a => (
<div key={a.id} style={{ background: 'var(--color-bg-secondary)', borderRadius: '10px', padding: '14px 16px', border: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: '36px', height: '36px', borderRadius: '8px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="folder" size={18} color={CAT_COLORS[a.category] || '#6B7280'} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 500 }}>{a.name}</span>
<span style={{ fontSize: '11px', padding: '1px 6px', borderRadius: '3px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{a.category_label}</span>
{a.file_type && <span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>{a.file_type}</span>}
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.size_formatted}</span>
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '2px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '16px', height: '16px', borderRadius: '50%' }} /> : null}
{a.username}
</span>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', color: a.status === 'active' ? 'var(--color-success)' : 'var(--color-danger)' }}>
{a.status === 'active' ? '活跃' : '已删除'}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.updated_at?.slice(0, 16) || '—'}</div>
</div>
</div>
))}
</div>
)}
</div>
);
export default AssetCenter;
+599
View File
@@ -0,0 +1,599 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface AutomationTask {
id: string;
name: string;
description: string;
trigger_type: string;
trigger_type_label: string;
cron_expression: string;
action_type: string;
action_type_label: string;
action_config: Record<string, any>;
event_condition: Record<string, any>;
is_enabled: boolean;
status: string;
max_retries: number;
timeout_seconds: number;
last_run_at: string;
next_run_at: string;
total_runs: number;
success_runs: number;
created_at: string;
updated_at: string;
}
interface ExecLog {
id: string;
task_id: string;
task_name: string;
status: string;
trigger_type: string;
started_at: string;
finished_at: string;
duration_ms: number;
result_summary: string;
error_message: string;
created_at: string;
}
interface AutomationStats {
total_tasks: number;
enabled_tasks: number;
running_tasks: number;
by_trigger: Record<string, number>;
recent_executions: number;
recent_success: number;
}
const TRIGGER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
cron: { label: '定时触发', color: '#6C63FF', bg: '#EDE9FE' },
webhook: { label: 'Webhook', color: '#059669', bg: '#D1FAE5' },
event: { label: '事件触发', color: '#D97706', bg: '#FEF3C7' },
};
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
idle: { label: '空闲', color: '#6B7280', bg: '#F3F4F6' },
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
success: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
failed: { label: '失败', color: '#EF4444', bg: '#FEE2E2' },
};
const PAGE_SIZE = 15;
const Automation: React.FC = () => {
// Data state
const [tasks, setTasks] = useState<AutomationTask[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<AutomationStats | null>(null);
// Filter state
const [filterTrigger, setFilterTrigger] = useState('');
const [keyword, setKeyword] = useState('');
// Modal state
const [modalVisible, setModalVisible] = useState(false);
const [editingTask, setEditingTask] = useState<AutomationTask | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
trigger_type: 'cron',
cron_expression: '',
event_condition_str: '',
action_type: 'ai_pipeline',
action_config_str: '{"pipeline_name": "default"}',
is_enabled: true,
max_retries: 3,
timeout_seconds: 300,
});
const [submitting, setSubmitting] = useState(false);
// Execution history
const [historyVisible, setHistoryVisible] = useState(false);
const [historyTaskId, setHistoryTaskId] = useState('');
const [historyTaskName, setHistoryTaskName] = useState('');
const [historyLogs, setHistoryLogs] = useState<ExecLog[]>([]);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotal, setHistoryTotal] = useState(0);
const [executingId, setExecutingId] = useState<string | null>(null);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminAutomationStats() as any;
setStats(result.data || null);
} catch (e) { /* ignore */ }
};
// Load tasks
const loadTasks = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (filterTrigger) params.trigger_type = filterTrigger;
if (keyword) params.keyword = keyword;
const result = await adminAPI.getAdminAutomationTasks(params) as any;
const data = result.data || result;
setTasks(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast('加载失败: ' + (e.message || e), 'error');
} finally {
setLoading(false);
}
};
useEffect(() => { loadStats(); }, []);
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
// Open create modal
const openCreateModal = () => {
setEditingTask(null);
setFormData({
name: '',
description: '',
trigger_type: 'cron',
cron_expression: '*/5 * * * *',
event_condition_str: '',
action_type: 'ai_pipeline',
action_config_str: '{"pipeline_name": "default"}',
is_enabled: true,
max_retries: 3,
timeout_seconds: 300,
});
setModalVisible(true);
};
// Open edit modal
const openEditModal = async (task: AutomationTask) => {
try {
const result = await adminAPI.getAdminAutomationTask(task.id) as any;
const detail = result.data;
setEditingTask(task);
setFormData({
name: detail.name || '',
description: detail.description || '',
trigger_type: detail.trigger_type || 'cron',
cron_expression: detail.cron_expression || '',
event_condition_str: detail.event_condition ? JSON.stringify(detail.event_condition, null, 2) : '',
action_type: detail.action_type || 'ai_pipeline',
action_config_str: detail.action_config ? JSON.stringify(detail.action_config, null, 2) : '{}',
is_enabled: detail.is_enabled !== false,
max_retries: detail.max_retries || 3,
timeout_seconds: detail.timeout_seconds || 300,
});
setModalVisible(true);
} catch (e: any) {
showToast('获取任务详情失败', 'error');
}
};
// Submit
const handleSubmit = async () => {
if (!formData.name.trim()) { showToast('请输入任务名称', 'error'); return; }
if (formData.trigger_type === 'cron' && !formData.cron_expression.trim()) {
showToast('请输入Cron表达式', 'error'); return;
}
let actionConfig: any = {};
if (formData.action_config_str.trim()) {
try { actionConfig = JSON.parse(formData.action_config_str); }
catch { showToast('操作配置JSON格式不正确', 'error'); return; }
}
let eventCondition: any = undefined;
if (formData.event_condition_str.trim()) {
try { eventCondition = JSON.parse(formData.event_condition_str); }
catch { showToast('事件条件JSON格式不正确', 'error'); return; }
}
setSubmitting(true);
try {
const data: any = {
name: formData.name.trim(),
description: formData.description.trim(),
trigger_type: formData.trigger_type,
cron_expression: formData.cron_expression.trim(),
action_type: formData.action_type,
action_config: actionConfig,
is_enabled: formData.is_enabled,
max_retries: formData.max_retries,
timeout_seconds: formData.timeout_seconds,
};
if (eventCondition) data.event_condition = eventCondition;
if (editingTask) {
await adminAPI.updateAdminAutomationTask(editingTask.id, data);
showToast('更新成功');
} else {
await adminAPI.createAdminAutomationTask(data);
showToast('创建成功');
}
setModalVisible(false);
loadTasks();
loadStats();
} catch (e: any) {
showToast('操作失败: ' + (e.message || e), 'error');
} finally {
setSubmitting(false);
}
};
// Delete
const handleDelete = async (task: AutomationTask) => {
if (!window.confirm(`确定要删除任务 "${task.name}" 吗?`)) return;
try {
await adminAPI.deleteAdminAutomationTask(task.id);
showToast('删除成功');
loadTasks();
loadStats();
} catch (e: any) {
showToast('删除失败: ' + (e.message || e), 'error');
}
};
// Toggle
const handleToggle = async (task: AutomationTask) => {
try {
await adminAPI.toggleAdminAutomationTask(task.id);
showToast(task.is_enabled ? '已禁用' : '已启用');
loadTasks();
loadStats();
} catch (e: any) {
showToast('操作失败: ' + (e.message || e), 'error');
}
};
// Execute now
const handleExecuteNow = async (task: AutomationTask) => {
setExecutingId(task.id);
try {
const result = await adminAPI.executeAdminAutomationTask(task.id) as any;
showToast(result.message || '执行完成');
loadTasks();
loadStats();
} catch (e: any) {
showToast('执行失败: ' + (e.message || e), 'error');
} finally {
setExecutingId(null);
}
};
// Show history
const showHistory = async (task: AutomationTask, pageNum: number = 1) => {
setHistoryTaskId(task.id);
setHistoryTaskName(task.name);
setHistoryPage(pageNum);
setHistoryVisible(true);
try {
const result = await adminAPI.getAdminAutomationTaskHistory(task.id, { page: pageNum, page_size: 20 }) as any;
const data = result.data || result;
setHistoryLogs(data.items || []);
setHistoryTotal(data.total || 0);
} catch (e) { /* ignore */ }
};
// Format time
const formatTime = (ts: string) => {
if (!ts) return '-';
try { return new Date(ts).toLocaleString('zh-CN'); }
catch { return ts; }
};
// Format duration
const formatDuration = (ms: number) => {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 };
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' };
const btnStyle = (bg: string, disabled = false): React.CSSProperties => ({
padding: '8px 20px', borderRadius: '6px', border: 'none', background: bg,
cursor: disabled ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff',
opacity: disabled ? 0.7 : 1,
});
return (
<div className="admin-page">
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
<h2>自动化任务调度</h2>
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button>
</div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div className="stat-card">
<div className="stat-value">{stats.total_tasks}</div>
<div className="stat-label">总任务数</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_tasks}</div>
<div className="stat-label">已启用</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3B82F6' }}>{stats.running_tasks}</div>
<div className="stat-label">运行中</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.recent_executions}</div>
<div className="stat-label">24小时执行</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.recent_success}</div>
<div className="stat-label">24小时成功</div>
</div>
</div>
)}
{/* Filter Bar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
<select value={filterTrigger} onChange={(e) => { setFilterTrigger(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}>
<option value="">全部类型</option>
{Object.entries(TRIGGER_CONFIG).map(([k, v]) => (<option key={k} value={k}>{v.label}</option>))}
</select>
<input type="text" placeholder="搜索任务名称..."
value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
</div>
{/* Table */}
<div style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
<th style={thStyle}>任务名称</th>
<th style={thStyle}>触发方式</th>
<th style={thStyle}>操作类型</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>上次执行</th>
<th style={thStyle}>下次执行</th>
<th style={thStyle}>成功率</th>
<th style={{ ...thStyle, textAlign: 'right', width: '320px' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} style={emptyStyle}>加载中...</td></tr>
) : tasks.length === 0 ? (
<tr><td colSpan={8} style={emptyStyle}>暂无自动化任务</td></tr>
) : (
tasks.map(task => (
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={tdStyle}>
<div style={{ fontWeight: 500 }}>{task.name}</div>
{task.description && <div style={{ fontSize: '12px', color: '#9CA3AF', marginTop: '2px' }}>{task.description}</div>}
</td>
<td style={tdStyle}>
<span style={badgeStyle(TRIGGER_CONFIG[task.trigger_type] || TRIGGER_CONFIG.cron)}>
{(TRIGGER_CONFIG[task.trigger_type] || {}).label || task.trigger_type}
</span>
{task.cron_expression && <div style={{ fontSize: '12px', fontFamily: 'monospace', marginTop: '2px', color: '#6B7280' }}>{task.cron_expression}</div>}
</td>
<td style={tdStyle}>{task.action_type_label}</td>
<td style={{ ...tdStyle, textAlign: 'center' }}>
<span style={badgeStyle(STATUS_CONFIG[task.status] || STATUS_CONFIG.idle)}>
{(STATUS_CONFIG[task.status] || {}).label || task.status}
</span>
</td>
<td style={tdStyle}>{formatTime(task.last_run_at)}</td>
<td style={tdStyle}>{formatTime(task.next_run_at)}</td>
<td style={tdStyle}>
{task.total_runs > 0
? <span style={{ color: task.success_runs / task.total_runs >= 0.9 ? '#10B981' : '#D97706' }}>
{Math.round(task.success_runs / task.total_runs * 100)}%
<span style={{ fontSize: '11px', color: '#9CA3AF' }}> ({task.success_runs}/{task.total_runs})</span>
</span>
: <span style={{ color: '#9CA3AF' }}>-</span>}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
{executingId === task.id ? '执行中...' : '执行'}
</button>
<button onClick={() => showHistory(task)}
style={actionBtnStyle('#374151')}>历史</button>
<button onClick={() => handleToggle(task)}
style={actionBtnStyle(task.is_enabled ? '#EF4444' : '#10B981')}>
{task.is_enabled ? '禁用' : '启用'}
</button>
<button onClick={() => openEditModal(task)}
style={actionBtnStyle('#374151')}>编辑</button>
<button onClick={() => handleDelete(task)}
style={actionBtnStyle('#EF4444', false, true)}>删除</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>
上一页
</button>
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)</span>
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}>
下一页
</button>
</div>
)}
{/* Create/Edit Modal */}
{modalVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div><label style={labelStyle}>任务名称 *</label>
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
<div><label style={labelStyle}>描述</label>
<input type="text" value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} style={inputStyle} placeholder="可选描述" /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div><label style={labelStyle}>触发方式</label>
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
<option value="cron">定时触发 (Cron)</option>
<option value="webhook">Webhook回调</option>
<option value="event">事件触发</option>
</select></div>
<div><label style={labelStyle}>操作类型</label>
<select value={formData.action_type} onChange={e => setFormData({ ...formData, action_type: e.target.value })} style={inputStyle}>
<option value="ai_pipeline">AI流水线</option>
<option value="webhook">Webhook回调</option>
<option value="notification">发送通知</option>
<option value="skill_execute">技能执行</option>
</select></div>
</div>
{formData.trigger_type === 'cron' && (
<div><label style={labelStyle}>Cron 表达式 *</label>
<input type="text" value={formData.cron_expression} onChange={e => setFormData({ ...formData, cron_expression: e.target.value })}
style={{ ...inputStyle, fontFamily: 'monospace' }} placeholder="*/5 * * * * (每5分钟)" />
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '4px' }}>
格式: 分 时 日 月 周 | 示例: 0 9 * * * (每天9点)
</div></div>
)}
{formData.trigger_type !== 'cron' && (
<div><label style={labelStyle}>事件条件 (JSON)</label>
<textarea value={formData.event_condition_str} onChange={e => setFormData({ ...formData, event_condition_str: e.target.value })}
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
placeholder={formData.trigger_type === 'webhook' ? '{"method": "POST", "secret": "xxx"}' : '{"event_type": "user.created"}'} /></div>
)}
<div><label style={labelStyle}>操作配置 (JSON)</label>
<textarea value={formData.action_config_str} onChange={e => setFormData({ ...formData, action_config_str: e.target.value })}
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
placeholder='{"pipeline_name": "default"}' /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
<div><label style={labelStyle}>重试次数</label>
<input type="number" value={formData.max_retries} onChange={e => setFormData({ ...formData, max_retries: parseInt(e.target.value) || 0 })}
min={0} max={10} style={inputStyle} /></div>
<div><label style={labelStyle}>超时(秒)</label>
<input type="number" value={formData.timeout_seconds} onChange={e => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
min={10} max={3600} style={inputStyle} /></div>
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
<input type="checkbox" checked={formData.is_enabled} onChange={e => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用</label></div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151' }}>取消</button>
<button onClick={handleSubmit} disabled={submitting} style={btnStyle('#6C63FF', submitting)}>
{submitting ? '保存中...' : (editingTask ? '保存修改' : '创建任务')}
</button>
</div>
</div>
</div>
)}
{/* History Modal */}
{historyVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '700px', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '18px' }}>执行历史: {historyTaskName}</h3>
<button onClick={() => setHistoryVisible(false)} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#6B7280' }}>✕</button>
</div>
{historyLogs.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB' }}>
<th style={thStyle}>时间</th>
<th style={thStyle}>触发方式</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>耗时</th>
<th style={thStyle}>结果</th>
</tr>
</thead>
<tbody>
{historyLogs.map(log => (
<tr key={log.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={tdStyle}>{formatTime(log.started_at)}</td>
<td style={tdStyle}>{log.trigger_type}</td>
<td style={tdStyle}>
<span style={badgeStyle(STATUS_CONFIG[log.status] || STATUS_CONFIG.idle)}>
{(STATUS_CONFIG[log.status] || {}).label || log.status}
</span>
</td>
<td style={tdStyle}>{formatDuration(log.duration_ms)}</td>
<td style={{ ...tdStyle, maxWidth: '250px' }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.result_summary || log.error_message || '-'}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* History pagination */}
{historyTotal > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={historyPage <= 1} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage - 1)}
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>上一页</button>
<span style={{ padding: '4px 8px', color: '#6B7280', fontSize: '13px' }}>{historyPage} / {Math.ceil(historyTotal / 20)}</span>
<button disabled={historyPage >= Math.ceil(historyTotal / 20)} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage + 1)}
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>下一页</button>
</div>
)}
</div>
</div>
)}
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 2000, padding: '12px 24px', borderRadius: '8px', color: '#fff',
background: toast.type === 'success' ? '#10B981' : '#EF4444', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px' }}>
{toast.msg}
</div>
)}
</div>
);
};
// Inline styles
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 };
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px' };
const emptyStyle: React.CSSProperties = { padding: '40px', textAlign: 'center', color: '#9CA3AF' };
const badgeStyle = (cfg: { color: string; bg: string }) => ({
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: cfg.color, background: cfg.bg,
} as React.CSSProperties);
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
padding: '5px 12px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
});
export default Automation;
+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; }
interface Package { id: string; name: string; description: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; }
interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; }
interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; }
const Billing: React.FC = () => {
const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview');
const [rules, setRules] = useState<Rule[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getBillingRules();
const d = (res as any)?.data || res;
setRules(d?.items || []);
} catch (e) { console.error('fetchRules:', e); }
setLoading(false);
}, []);
const fetchPackages = useCallback(async () => {
try {
const res = await adminAPI.getBillingPackages();
const d = (res as any)?.data || res;
setPackages(Array.isArray(d) ? d : d?.items || []);
} catch (e) { console.error('fetchPackages:', e); }
}, []);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getBillingTransactions(page);
const d = (res as any)?.data || res;
setTransactions(d?.items || []);
setTotal(d?.total || 0);
} catch (e) { console.error('fetchTransactions:', e); }
setLoading(false);
}, [page]);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getBillingStats();
setStats((res as any)?.data || res);
} catch (e) { console.error('fetchStats:', e); }
}, []);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]);
useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]);
useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]);
const typeColors: Record<string, string> = { per_token: '#6C63FF', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
return (
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
{/* Stats */}
{stats && (
<div className="responsive-grid-4" style={{ gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' },
{ label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' },
{ label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' },
{ label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' },
].map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280', fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
</div>
{/* Overview */}
{tab === 'overview' && stats && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px', color: 'var(--color-text-primary)' }}>近7天收入趋势</h3>
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
{stats.daily_revenue_7d.map((d, i) => {
const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1);
const h = (d.revenue / max) * 160;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '11px', color: '#6B7280', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6C63FF, #818cf8)', borderRadius: '4px 4px 0 0' }} />
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '6px' }}>{d.date.substring(5)}</div>
</div>
);
})}
</div>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>暂无收入数据</div>}
</div>
)}
{/* Rules */}
{tab === 'rules' && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead><tr style={{ background: '#F8F9FC' }}>
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{rules.map(r => (
<tr key={r.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.model_name || '通用'}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.free_quota?.toLocaleString() || 0}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: r.status === 'active' ? '#10b981' : '#9CA3AF', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Packages */}
{tab === 'packages' && (
<div className="responsive-grid-4" style={{ gap: '16px' }}>
{packages.map(p => (
<div key={p.id} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6C63FF' : '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
{p.is_popular === 1 && (
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6C63FF', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
)}
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
<p style={{ fontSize: '13px', color: '#6B7280', marginBottom: '16px' }}>{p.description || ''}</p>
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6C63FF' }}>¥{p.price}</div>
{p.original_price && <div style={{ fontSize: '14px', color: '#9CA3AF', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid rgba(0,0,0,0.08)', fontSize: '13px', color: '#6B7280' }}>
<div>主额度: <span style={{ color: '#1A1F36', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
</div>
<div style={{ marginTop: '12px', fontSize: '12px' }}>
<span style={{ color: p.status === 'active' ? '#10b981' : '#9CA3AF' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
</div>
</div>
))}
</div>
)}
{/* Transactions */}
{tab === 'transactions' && (
<>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead><tr style={{ background: '#F8F9FC' }}>
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{transactions.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 16)}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.tokens?.toLocaleString() || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.description || '-'}</td>
</tr>
))}
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无交易记录</td></tr>}
</tbody>
</table>
)}
{total > 20 && (
<div className="pagination-bar" style={{ marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</>
)}
</div>
);
};
export default Billing;
+747
View File
@@ -0,0 +1,747 @@
import React, { useState, useEffect, useRef } from 'react';
import { adminAPI } from '@/api';
interface ExecStats {
total: number;
today: number;
success: number;
failed: number;
timeout: number;
avg_execution_time_ms: number;
by_language: Record<string, number>;
}
interface RuntimeInfo {
python?: string;
nodejs?: string;
max_code_length?: number;
max_timeout?: number;
}
interface TaskItem {
id: string;
language: string;
code_preview: string;
status: string;
exit_code: number | null;
execution_time_ms: number | null;
error_msg: string | null;
truncated: boolean;
created_at: string;
completed_at: string | null;
}
interface ExecResult {
task_id: string;
success: boolean;
stdout: string;
stderr: string;
exit_code: number;
execution_time_ms: number;
error: string | null;
truncated: boolean;
created_at: string;
}
const LANG_CONFIG: Record<string, { label: string; color: string; bg: string; placeholder: string }> = {
python: { label: 'Python', color: '#306998', bg: '#E8F4FD', placeholder: '# Python 代码示例\nprint("Hello, sandbox!")\n\nfor i in range(5):\n print(f"Line {i}")' },
javascript: { label: 'JavaScript', color: '#F7DF1E', bg: '#FFFDE8', placeholder: '// JavaScript 代码示例\nconsole.log("Hello, sandbox!");\n\nfor (let i = 0; i < 5; i++) {\n console.log(`Line ${i}`);\n}' },
};
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
error: { label: '错误', color: '#EF4444', bg: '#FEE2E2' },
failed: { label: '失败', color: '#F59E0B', bg: '#FEF3C7' },
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
};
const PAGE_SIZE = 15;
const CodeExec: React.FC = () => {
// Stats
const [stats, setStats] = useState<ExecStats | null>(null);
const [runtimes, setRuntimes] = useState<RuntimeInfo | null>(null);
// Editor
const [language, setLanguage] = useState('python');
const [code, setCode] = useState(LANG_CONFIG.python.placeholder);
const [timeout, setTimeout_] = useState(30);
// Execution
const [executing, setExecuting] = useState(false);
const [result, setResult] = useState<ExecResult | null>(null);
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout');
// Task History
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [filterLang, setFilterLang] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Task Detail Modal
const [detailTask, setDetailTask] = useState<TaskItem | null>(null);
const [detailResult, setDetailResult] = useState<ExecResult | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const outputRef = useRef<HTMLDivElement>(null);
// Load stats & runtimes on mount
useEffect(() => {
loadStats();
loadRuntimes();
}, []);
// Load tasks
useEffect(() => {
loadTasks();
}, [page, filterLang, filterStatus]);
const loadStats = async () => {
try {
const res = await adminAPI.getCodeExecStats() as any;
setStats(res.data || null);
} catch (e) { /* ignore */ }
};
const loadRuntimes = async () => {
try {
const res = await adminAPI.getCodeExecRuntimes() as any;
setRuntimes(res.data || null);
} catch (e) { /* ignore */ }
};
const loadTasks = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (filterLang) params.language = filterLang;
if (filterStatus) params.status = filterStatus;
const res = await adminAPI.getCodeExecTasks(params) as any;
const data = res.data || res;
setTasks(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast('加载任务列表失败: ' + (e.message || e), 'error');
} finally {
setLoading(false);
}
};
// Execute code
const handleExecute = async () => {
if (!code.trim()) {
showToast('请输入代码', 'error');
return;
}
setExecuting(true);
setResult(null);
setActiveTab('stdout');
try {
const res = await adminAPI.executeCode({ language, code: code.trim(), timeout }) as any;
setResult(res.data || res);
} catch (e: any) {
setResult({
task_id: '',
success: false,
stdout: '',
stderr: '',
exit_code: -1,
execution_time_ms: 0,
error: e.message || '执行失败',
truncated: false,
created_at: '',
});
showToast('执行失败: ' + (e.message || e), 'error');
} finally {
setExecuting(false);
loadStats();
loadTasks();
}
};
// View task detail
const viewTaskDetail = async (task: TaskItem) => {
setDetailTask(task);
setDetailResult(null);
setDetailLoading(true);
try {
const res = await adminAPI.getCodeExecTask(task.id) as any;
setDetailResult(res.data || null);
} catch (e: any) {
showToast('获取任务详情失败', 'error');
} finally {
setDetailLoading(false);
}
};
// Delete task
const handleDeleteTask = async (task: TaskItem) => {
if (!window.confirm(`确定要删除任务 ${task.id} 吗?`)) return;
try {
await adminAPI.deleteCodeExecTask(task.id);
showToast('删除成功');
if (detailTask?.id === task.id) {
setDetailTask(null);
setDetailResult(null);
}
loadTasks();
loadStats();
} catch (e: any) {
showToast('删除失败: ' + (e.message || e), 'error');
}
};
// Keyboard shortcut: Ctrl/Cmd+Enter to execute
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleExecute();
}
};
return (
<div className="admin-page">
<div className="admin-page-header">
<h2>代码执行沙箱</h2>
<span style={{ fontSize: '13px', color: '#6B7280', marginLeft: '12px' }}>
安全隔离的 Python / JavaScript 代码执行环境
</span>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
<div className="stat-card">
<div className="stat-value">{stats.today}</div>
<div className="stat-label">今日执行</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.success}</div>
<div className="stat-label">成功</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#EF4444' }}>{stats.failed + stats.timeout}</div>
<div className="stat-label">失败/超时</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.avg_execution_time_ms.toFixed(0)}ms</div>
<div className="stat-label">平均耗时</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">总执行次数</div>
</div>
{/* Language breakdown */}
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginTop: '4px' }}>
{Object.entries(stats.by_language || {}).map(([lang, count]) => (
<span key={lang} style={{
padding: '2px 12px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[lang] || {}).color || '#666',
background: (LANG_CONFIG[lang] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[lang] || {}).label || lang}: {count}
</span>
))}
{Object.keys(stats.by_language || {}).length === 0 && (
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>暂无数据</span>
)}
</div>
<div className="stat-label">语言分布</div>
</div>
</div>
)}
{/* Main grid: Editor + Output */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '24px' }}>
{/* Left: Code Editor */}
<div style={{
background: '#F8F9FC', borderRadius: '12px', border: '1px solid #313244',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Editor header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #313244',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FF5F57' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FFBD2E' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#27CA40' }} />
</div>
<select
value={language}
onChange={(e) => {
setLanguage(e.target.value);
setCode(LANG_CONFIG[e.target.value]?.placeholder || '');
setResult(null);
}}
style={{
padding: '4px 12px', borderRadius: '6px', border: '1px solid #45475A',
background: '#313244', color: '#CDD6F4', fontSize: '13px', cursor: 'pointer',
}}
>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<span style={{ fontSize: '12px', color: '#6C7086' }}>
{runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '12px', color: '#6C7086' }}>超时:</span>
<input
type="range"
min={1}
max={120}
value={timeout}
onChange={(e) => setTimeout_(parseInt(e.target.value))}
style={{ width: '80px', accentColor: '#6C63FF' }}
/>
<span style={{ fontSize: '12px', color: '#CDD6F4', minWidth: '28px' }}>{timeout}s</span>
<button
onClick={handleExecute}
disabled={executing}
style={{
padding: '6px 16px', borderRadius: '6px', border: 'none',
background: executing ? '#45475A' : '#6C63FF',
color: '#1A1F36', cursor: executing ? 'not-allowed' : 'pointer',
fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
}}
>
{executing ? '执行中...' : '▶ 运行'}
</button>
</div>
</div>
{/* Code textarea */}
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={LANG_CONFIG[language]?.placeholder || '输入代码...'}
spellCheck={false}
style={{
flex: 1, minHeight: '320px', padding: '16px', border: 'none',
background: '#F8F9FC', color: '#CDD6F4', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
fontSize: '13px', lineHeight: 1.6, resize: 'vertical',
outline: 'none', tabSize: 4,
}}
/>
{/* Footer hint */}
<div style={{
padding: '6px 16px', borderTop: '1px solid #313244',
fontSize: '11px', color: '#585B70',
}}>
Ctrl+Enter 运行 · {language === 'python' ? 'Python 3.11' : 'Node.js'} · 最大 {runtimes?.max_code_length ? (runtimes.max_code_length / 1024).toFixed(0) + 'KB' : '50KB'}
</div>
</div>
{/* Right: Output */}
<div style={{
background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Output header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #E5E7EB',
background: '#F9FAFB',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#374151' }}>执行结果</span>
{result && (
<span style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '11px',
color: result.success ? '#fff' : '#fff',
background: result.success ? '#10B981' : (result.error ? '#EF4444' : '#F59E0B'),
}}>
{result.success ? '成功' : (result.error?.includes('timeout') ? '超时' : '失败')}
</span>
)}
{result && !result.success && result.exit_code !== 0 && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
exit_code={result.exit_code}
</span>
)}
{result && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
{result.execution_time_ms.toFixed(0)}ms
</span>
)}
</div>
{result && (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => setActiveTab('stdout')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stdout' ? '#6C63FF' : '#F3F4F6',
color: activeTab === 'stdout' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stdout
</button>
<button
onClick={() => setActiveTab('stderr')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stderr' ? '#EF4444' : '#F3F4F6',
color: activeTab === 'stderr' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stderr{result.stderr ? ` (${result.stderr.length})` : ''}
</button>
</div>
)}
</div>
{/* Output content */}
<div ref={outputRef} style={{
flex: 1, minHeight: '320px', padding: '16px', overflow: 'auto',
background: '#F8F9FC',
}}>
{executing ? (
<div style={{ color: '#6C7086', fontSize: '13px', fontFamily: 'monospace' }}>
<span style={{ animation: 'blink 1s infinite' }}>⏳</span> 正在执行...
</div>
) : !result ? (
<div style={{ color: '#585B70', fontSize: '13px', fontFamily: 'monospace', textAlign: 'center', paddingTop: '60px' }}>
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{'</>'}</div>
<div>点击「运行」按钮执行代码</div>
<div style={{ marginTop: '8px', fontSize: '11px' }}>或按 Ctrl+Enter 快捷运行</div>
</div>
) : (
<>
{result.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px',
fontFamily: "'Fira Code', monospace",
}}>
{result.error}
</div>
)}
{result.truncated && (
<div style={{
padding: '4px 12px', marginBottom: '8px', borderRadius: '4px',
background: '#FEF3C7', color: '#92400E', fontSize: '11px', fontFamily: 'monospace',
}}>
输出已截断 (超过 500KB)
</div>
)}
<pre style={{
margin: 0, padding: 0, background: 'transparent', color: '#CDD6F4',
fontSize: '13px', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
minHeight: activeTab === 'stdout' && !result.stdout.trim() ? 'auto' : undefined,
}}>
{activeTab === 'stdout'
? (result.stdout.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
: (result.stderr.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
}
</pre>
{result.task_id && (
<div style={{ marginTop: '12px', padding: '6px 0', borderTop: '1px solid #313244', fontSize: '11px', color: '#585B70', fontFamily: 'monospace' }}>
Task ID: {result.task_id}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Task History */}
<div style={{ marginTop: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', color: '#374151' }}>执行历史</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<select
value={filterLang}
onChange={(e) => { setFilterLang(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部语言</option>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部状态</option>
<option value="completed">成功</option>
<option value="error">错误</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
</select>
<button
onClick={loadTasks}
style={{
padding: '6px 12px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#374151',
}}
>
刷新
</button>
</div>
</div>
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '100px' }}>任务ID</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>语言</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>代码预览</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>状态</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>耗时</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '160px' }}>创建时间</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '120px' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
) : tasks.length === 0 ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</td></tr>
) : (
tasks.map(task => (
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={{ padding: '10px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6C63FF' }}>
{task.id}
</td>
<td style={{ padding: '10px 16px' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (LANG_CONFIG[task.language] || {}).color || '#666',
background: (LANG_CONFIG[task.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[task.language] || {}).label || task.language}
</span>
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.code_preview || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (STATUS_CONFIG[task.status] || {}).color || '#666',
background: (STATUS_CONFIG[task.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[task.status] || {}).label || task.status}
</span>
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
{task.execution_time_ms != null ? `${task.execution_time_ms}ms` : '-'}
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280' }}>
{task.created_at || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
<button
onClick={() => viewTaskDetail(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '11px', color: '#374151',
}}
>
详情
</button>
<button
onClick={() => handleDeleteTask(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #FEE2E2',
background: '#FEF2F2', cursor: 'pointer', fontSize: '11px', color: '#EF4444',
}}
>
删除
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer',
opacity: page <= 1 ? 0.5 : 1,
}}
>
上一页
</button>
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
</span>
<button
disabled={page >= Math.ceil(total / PAGE_SIZE)}
onClick={() => setPage(p => p + 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
}}
>
下一页
</button>
</div>
)}
</div>
{/* Task Detail Modal */}
{detailTask && (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
}} onClick={() => { setDetailTask(null); setDetailResult(null); }}>
<div className="modal-content" style={{
background: '#fff', borderRadius: '12px', padding: '24px',
width: '700px', maxHeight: '85vh', overflow: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>任务详情 - {detailTask.id}</h3>
<button
onClick={() => { setDetailTask(null); setDetailResult(null); }}
style={{ padding: '4px 8px', borderRadius: '4px', border: 'none', background: '#F3F4F6', cursor: 'pointer', fontSize: '16px', color: '#6B7280' }}
>
✕
</button>
</div>
{/* Task info bar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[detailTask.language] || {}).color || '#666',
background: (LANG_CONFIG[detailTask.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[detailTask.language] || {}).label || detailTask.language}
</span>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (STATUS_CONFIG[detailTask.status] || {}).color || '#666',
background: (STATUS_CONFIG[detailTask.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[detailTask.status] || {}).label || detailTask.status}
</span>
{detailTask.execution_time_ms != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
耗时: {detailTask.execution_time_ms}ms
</span>
)}
{detailTask.exit_code != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
exit_code: {detailTask.exit_code}
</span>
)}
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>
{detailTask.created_at}
</span>
</div>
{/* Code preview */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>代码预览</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '150px', overflow: 'auto',
}}>
{detailTask.code_preview || '(无)'}
</pre>
</div>
{/* Full output */}
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div>
) : detailResult ? (
<>
{detailResult.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px', fontFamily: 'monospace',
}}>
{detailResult.error}
</div>
)}
{detailResult.stdout && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>stdout</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stdout}
</pre>
</div>
)}
{detailResult.stderr && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#EF4444', fontWeight: 500 }}>stderr</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#F38BA8', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stderr}
</pre>
</div>
)}
{!detailResult.stdout.trim() && !detailResult.stderr.trim() && !detailResult.error && (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>(无输出)</div>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>无法获取详细信息</div>
)}
</div>
</div>
)}
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
padding: '12px 24px', borderRadius: '8px', color: '#1A1F36',
background: toast.type === 'success' ? '#10B981' : '#EF4444',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
}}>
{toast.msg}
</div>
)}
</div>
);
};
export default CodeExec;
+155
View File
@@ -0,0 +1,155 @@
.admin-developer { padding: 20px; max-width: 1400px; margin: 0 auto; }
.dev-header { margin-bottom: 24px; }
.dev-header h2 { font-size: 22px; margin: 0 0 6px 0; }
.dev-header p { color: var(--text-secondary, #666); font-size: 14px; margin: 0; }
/* Stats Grid */
.dev-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
.dev-stat-card {
display: flex; align-items: center; gap: 14px;
padding: 18px; border-radius: 10px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e8e8e8);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.stat-icon { font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 10px; flex-shrink: 0; }
.icon-key { background: rgba(24,144,255,0.1); }
.icon-client { background: rgba(82,196,26,0.1); }
.icon-call { background: rgba(250,173,20,0.1); }
.icon-error { background: rgba(245,34,45,0.1); }
.icon-time { background: rgba(114,46,209,0.1); }
.stat-info { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary, #666); margin-top: 2px; }
.stat-sub { font-size: 11px; color: var(--text-tertiary, #999); margin-top: 2px; }
/* Tabs */
.dev-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border-color, #e8e8e8); margin-bottom: 20px; }
.dev-tab {
padding: 10px 20px; border: none; background: none; cursor: pointer;
font-size: 14px; color: var(--text-secondary, #666);
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
}
.dev-tab.active { color: var(--primary, #1890ff); border-bottom-color: var(--primary, #1890ff); font-weight: 600; }
.dev-tab:hover { color: var(--primary, #1890ff); }
/* Toolbar */
.dev-toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.dev-input {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; flex: 1; min-width: 200px; max-width: 400px;
background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
.dev-input:focus { outline: none; border-color: var(--primary, #1890ff); box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
.dev-select {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
/* Buttons */
.dev-btn {
padding: 8px 16px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
transition: all 0.2s; white-space: nowrap;
}
.dev-btn:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn.primary { background: var(--primary, #1890ff); color: #fff; border-color: var(--primary, #1890ff); }
.dev-btn.primary:hover { background: var(--primary-dark, #096dd9); color: #fff; }
.dev-btn.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
.dev-btn-sm {
padding: 4px 10px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
font-size: 12px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
margin-right: 4px; transition: all 0.2s;
}
.dev-btn-sm:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn-sm.warn { color: var(--warning, #faad14); border-color: var(--warning, #faad14); }
.dev-btn-sm.warn:hover { background: var(--warning, #faad14); color: #fff; }
.dev-btn-sm.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn-sm.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
/* Table */
.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.dev-table th {
text-align: left; padding: 10px 12px; background: var(--table-header-bg, #fafafa);
border-bottom: 2px solid var(--border-color, #e8e8e8); font-weight: 600; white-space: nowrap;
color: var(--text-primary, #333);
}
.dev-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f0f0); }
.dev-table tbody tr:hover { background: var(--table-hover-bg, #f5f5f5); }
.mono-text { font-family: 'Courier New', monospace; font-size: 12px; }
.dev-actions { white-space: nowrap; }
.dev-empty { text-align: center; color: var(--text-tertiary, #999); padding: 40px 12px !important; }
.dev-loading { text-align: center; padding: 40px; color: var(--text-secondary, #666); }
/* Badges */
.dev-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500;
}
.badge-active { background: rgba(82,196,26,0.1); color: #52c41a; }
.badge-revoked { background: rgba(245,34,45,0.1); color: #f5222d; }
.badge-disabled { background: rgba(250,173,20,0.1); color: #faad14; }
/* Method badges */
.method-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
.method-get { background: rgba(82,196,26,0.1); color: #52c41a; }
.method-post { background: rgba(24,144,255,0.1); color: #1890ff; }
.method-put { background: rgba(250,173,20,0.1); color: #faad14; }
.method-delete { background: rgba(245,34,45,0.1); color: #f5222d; }
.method-patch { background: rgba(114,46,209,0.1); color: #722ed1; }
/* Text colors */
.text-danger { color: var(--danger, #f5222d); }
.text-warn { color: var(--warning, #faad14); }
/* Pagination */
.dev-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; }
.dev-pagination button {
padding: 6px 14px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333); font-size: 13px;
}
.dev-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.dev-pagination span { font-size: 13px; color: var(--text-secondary, #666); }
/* Modal */
.dev-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.dev-modal {
background: var(--modal-bg, #fff); border-radius: 10px; padding: 24px;
width: 460px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
}
.dev-modal h3 { margin: 0 0 20px 0; font-size: 18px; }
.dev-form-group { margin-bottom: 16px; }
.dev-form-group label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--text-secondary, #666); }
.dev-form-group .dev-input { width: 100%; max-width: none; box-sizing: border-box; }
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
@media (max-width: 1200px) {
.dev-stats-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
.dev-table { font-size: 12px; }
}
/* Phase 3.9: Developer Tools styles */
.dev-method { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: monospace; }
.method-get { background: rgba(82,196,26,0.2); color: #52c41a; }
.method-post { background: rgba(22,119,255,0.2); color: #1677ff; }
.method-put { background: rgba(250,173,20,0.2); color: #faad14; }
.method-delete { background: rgba(255,77,79,0.2); color: #ff4d4f; }
.dev-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.dev-modal { background: #FFFFFF; border: 1px solid rgba(0,0,0,0.10); border-radius: 12px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.dev-modal h3 { margin: 0 0 16px; color: #1A1F36; }
.dev-form-group { margin-bottom: 14px; }
.dev-form-group label { display: block; font-size: 13px; color: #5A6480; margin-bottom: 4px; }
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.dev-muted { color: #8892B0; font-size: 12px; }
.dev-test-result { margin: 12px 0; padding: 12px; border-radius: 8px; font-size: 14px; }
+515
View File
@@ -0,0 +1,515 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Stats {
api_keys: { total: number; active: number; revoked: number };
openapi_clients: { total: number; active: number };
call_stats: { total_calls: number; today_calls: number; total_errors: number; error_rate: number; avg_response_time: number };
slow_logs: { total: number; today: number };
top_endpoints: { endpoint: string; method: string; call_count: number; error_count: number }[];
server_time: string;
}
interface ApiKey {
id: string; user_id: string; username: string | null; api_key: string; name: string;
description: string; rate_limit: number; total_calls: number; status: string;
last_used_at: string | null; created_at: string; expired_at: string | null;
}
interface OpenApiClient {
id: string; user_id: string; username: string | null; name: string; client_id: string;
allowed_models: string[]; rate_limit: number; total_calls: number;
last_used_at: string | null; status: string; created_at: string;
}
interface CallStat {
id: string; endpoint: string; method: string; call_count: number; error_count: number;
error_rate: number; avg_response_time: number; total_response_time: number;
last_called_at: string | null; created_at: string; updated_at: string;
}
interface SlowLog {
id: string; request_id: string | null; method: string; endpoint: string;
status_code: number; response_time: number; threshold: number; created_at: string;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloper() {
const [activeTab, setActiveTab] = useState<'keys' | 'clients' | 'stats' | 'logs'>('keys');
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
// API Keys state
const [keys, setKeys] = useState<PageData<ApiKey>>({ total: 0, page: 1, page_size: 20, items: [] });
const [keySearch, setKeySearch] = useState('');
const [keyStatusFilter, setKeyStatusFilter] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
const [keyForm, setKeyForm] = useState({ user_id: '', name: '', description: '', rate_limit: 60 });
// OpenAPI Clients state
const [clients, setClients] = useState<PageData<OpenApiClient>>({ total: 0, page: 1, page_size: 20, items: [] });
const [clientSearch, setClientSearch] = useState('');
const [showClientModal, setShowClientModal] = useState(false);
const [clientForm, setClientForm] = useState({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
// Call Stats state
const [callStats, setCallStats] = useState<PageData<CallStat>>({ total: 0, page: 1, page_size: 20, items: [] });
const [statSearch, setStatSearch] = useState('');
const [statSortBy, setStatSortBy] = useState('call_count');
// Slow Logs state
const [slowLogs, setSlowLogs] = useState<PageData<SlowLog>>({ total: 0, page: 1, page_size: 20, items: [] });
const [logSearch, setLogSearch] = useState('');
const loadStats = useCallback(async () => {
try {
const res = await api('/admin/developer/stats/overview');
if (res.code === 0) setStats(res.data);
} catch (e) { console.error('Stats error:', e); }
}, []);
const loadKeys = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (keyStatusFilter) params.set('status', keyStatusFilter);
if (keySearch) params.set('keyword', keySearch);
const res = await api(`/admin/developer/api-keys?${params}`);
if (res.code === 0) setKeys(res.data);
} catch (e) { console.error('Keys error:', e); }
setLoading(false);
}, [keySearch, keyStatusFilter]);
const loadClients = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (clientSearch) params.set('keyword', clientSearch);
const res = await api(`/admin/developer/openapi-clients?${params}`);
if (res.code === 0) setClients(res.data);
} catch (e) { console.error('Clients error:', e); }
setLoading(false);
}, [clientSearch]);
const loadCallStats = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20', sort_by: statSortBy });
if (statSearch) params.set('keyword', statSearch);
const res = await api(`/admin/developer/call-stats?${params}`);
if (res.code === 0) setCallStats(res.data);
} catch (e) { console.error('Stats error:', e); }
setLoading(false);
}, [statSearch, statSortBy]);
const loadSlowLogs = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (logSearch) params.set('keyword', logSearch);
const res = await api(`/admin/developer/slow-logs?${params}`);
if (res.code === 0) setSlowLogs(res.data);
} catch (e) { console.error('Logs error:', e); }
setLoading(false);
}, [logSearch]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { if (activeTab === 'keys') loadKeys(); }, [activeTab, loadKeys]);
useEffect(() => { if (activeTab === 'clients') loadClients(); }, [activeTab, loadClients]);
useEffect(() => { if (activeTab === 'stats') loadCallStats(); }, [activeTab, loadCallStats]);
useEffect(() => { if (activeTab === 'logs') loadSlowLogs(); }, [activeTab, loadSlowLogs]);
const handleCreateKey = async () => {
try {
const body: any = { user_id: keyForm.user_id, name: keyForm.name, description: keyForm.description, rate_limit: keyForm.rate_limit };
await api('/admin/developer/api-keys/', { method: 'POST', body: JSON.stringify(body) });
setShowKeyModal(false);
setKeyForm({ user_id: '', name: '', description: '', rate_limit: 60 });
loadKeys();
loadStats();
} catch (e: any) { alert(e.message); }
};
const handleRevokeKey = async (id: string) => {
if (!confirm('Revoke this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}/revoke`, { method: 'POST' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleDeleteKey = async (id: string) => {
if (!confirm('Permanently delete this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}`, { method: 'DELETE' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleCreateClient = async () => {
try {
const models = clientForm.allowed_models ? clientForm.allowed_models.split(',').map(s => s.trim()).filter(Boolean) : null;
const body: any = { user_id: clientForm.user_id, name: clientForm.name, allowed_models: models, rate_limit: clientForm.rate_limit };
const res = await api('/admin/developer/openapi-clients/', { method: 'POST', body: JSON.stringify(body) });
setShowClientModal(false);
setClientForm({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
loadClients(); loadStats();
if (res.data?.client_secret) {
alert(`Client created! Secret: ${res.data.client_secret}\nSave this secret - it won't be shown again.`);
}
} catch (e: any) { alert(e.message); }
};
const handleToggleClient = async (client: OpenApiClient) => {
const newStatus = client.status === 'active' ? 'disabled' : 'active';
try {
await api(`/admin/developer/openapi-clients/${client.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadClients();
} catch (e: any) { alert(e.message); }
};
const handleDeleteClient = async (id: string) => {
if (!confirm('Delete this OpenAPI client?')) return;
try {
await api(`/admin/developer/openapi-clients/${id}`, { method: 'DELETE' });
loadClients(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleResetSecret = async (id: string) => {
if (!confirm('Reset client secret? The old secret will be invalidated.')) return;
try {
const res = await api(`/admin/developer/openapi-clients/${id}/reset-secret`, { method: 'POST' });
if (res.data?.client_secret) alert(`New secret: ${res.data.client_secret}`);
} catch (e: any) { alert(e.message); }
};
const handleClearLogs = async () => {
if (!confirm('Clear ALL slow request logs?')) return;
try {
await api('/admin/developer/slow-logs', { method: 'DELETE' });
loadSlowLogs(); loadStats();
} catch (e: any) { alert(e.message); }
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'revoked' ? 'badge-revoked' : 'badge-disabled';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者API管理</h2>
<p>管理API密钥、OpenAPI客户端、调用统计和慢请求日志</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="dev-stats-grid">
<div className="dev-stat-card">
<div className="stat-icon icon-key">🔑</div>
<div className="stat-info">
<span className="stat-value">{stats.api_keys.total}</span>
<span className="stat-label">API密钥总数</span>
<span className="stat-sub">活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-client">🔌</div>
<div className="stat-info">
<span className="stat-value">{stats.openapi_clients.total}</span>
<span className="stat-label">OpenAPI客户端</span>
<span className="stat-sub">活跃 {stats.openapi_clients.active}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-call">📞</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.total_calls.toLocaleString()}</span>
<span className="stat-label">API总调用</span>
<span className="stat-sub">今日 {stats.call_stats.today_calls.toLocaleString()}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-error">⚠️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.error_rate}%</span>
<span className="stat-label">错误率</span>
<span className="stat-sub">错误 {stats.call_stats.total_errors.toLocaleString()} 次</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-time">⏱️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.avg_response_time}s</span>
<span className="stat-label">平均响应时间</span>
<span className="stat-sub">慢请求 {stats.slow_logs.total} 条</span>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'keys' ? 'active' : ''}`} onClick={() => setActiveTab('keys')}>API密钥</button>
<button className={`dev-tab ${activeTab === 'clients' ? 'active' : ''}`} onClick={() => setActiveTab('clients')}>OpenAPI客户端</button>
<button className={`dev-tab ${activeTab === 'stats' ? 'active' : ''}`} onClick={() => setActiveTab('stats')}>调用统计</button>
<button className={`dev-tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>慢请求日志</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* API Keys Tab */}
{activeTab === 'keys' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索密钥名称/用户..." value={keySearch} onChange={e => setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} />
<select className="dev-select" value={keyStatusFilter} onChange={e => setKeyStatusFilter(e.target.value)}>
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="revoked">已撤销</option>
</select>
<button className="dev-btn primary" onClick={() => setShowKeyModal(true)}>+ 新建密钥</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>API Key</th><th>用户</th><th>速率限制</th><th>状态</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{keys.items.map(k => (
<tr key={k.id}>
<td>{k.name || '-'}</td>
<td className="mono-text">{k.api_key.slice(0, 12)}...</td>
<td>{k.username || k.user_id.slice(0, 8)}</td>
<td>{k.rate_limit}/min</td>
<td>{statusBadge(k.status)}</td>
<td>{fmtTime(k.last_used_at)}</td>
<td>{fmtTime(k.created_at)}</td>
<td className="dev-actions">
{k.status === 'active' && <button className="dev-btn-sm warn" onClick={() => handleRevokeKey(k.id)}>撤销</button>}
<button className="dev-btn-sm danger" onClick={() => handleDeleteKey(k.id)}>删除</button>
</td>
</tr>
))}
{keys.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无API密钥</td></tr>}
</tbody>
</table>
{keys.total > 20 && (
<div className="dev-pagination">
<button disabled={keys.page <= 1} onClick={() => loadKeys(keys.page - 1)}>上一页</button>
<span>第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页</span>
<button disabled={keys.page >= Math.ceil(keys.total / 20)} onClick={() => loadKeys(keys.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* OpenAPI Clients Tab */}
{activeTab === 'clients' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索客户端名称/ID..." value={clientSearch} onChange={e => setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} />
<button className="dev-btn primary" onClick={() => setShowClientModal(true)}>+ 新建客户端</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>Client ID</th><th>用户</th><th>允许模型</th><th>速率限制</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{clients.items.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td className="mono-text">{c.client_id}</td>
<td>{c.username || c.user_id.slice(0, 8)}</td>
<td>{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}</td>
<td>{c.rate_limit}/min</td>
<td>{statusBadge(c.status)}</td>
<td>{fmtTime(c.created_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleToggleClient(c)}>{c.status === 'active' ? '禁用' : '启用'}</button>
<button className="dev-btn-sm warn" onClick={() => handleResetSecret(c.id)}>重置密钥</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteClient(c.id)}>删除</button>
</td>
</tr>
))}
{clients.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无OpenAPI客户端</td></tr>}
</tbody>
</table>
{clients.total > 20 && (
<div className="dev-pagination">
<button disabled={clients.page <= 1} onClick={() => loadClients(clients.page - 1)}>上一页</button>
<span>第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页</span>
<button disabled={clients.page >= Math.ceil(clients.total / 20)} onClick={() => loadClients(clients.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Call Stats Tab */}
{activeTab === 'stats' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={statSearch} onChange={e => setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} />
<select className="dev-select" value={statSortBy} onChange={e => setStatSortBy(e.target.value)}>
<option value="call_count">按调用次数</option>
<option value="error_count">按错误数</option>
<option value="total_response_time">按总耗时</option>
<option value="last_called_at">按最后调用</option>
</select>
</div>
<table className="dev-table">
<thead>
<tr><th>端点</th><th>方法</th><th>调用次数</th><th>错误数</th><th>错误率</th><th>平均耗时</th><th>最后调用</th></tr>
</thead>
<tbody>
{callStats.items.map(s => (
<tr key={s.id}>
<td className="mono-text">{s.endpoint}</td>
<td><span className={`method-badge method-${s.method.toLowerCase()}`}>{s.method}</span></td>
<td>{s.call_count.toLocaleString()}</td>
<td className={s.error_count > 0 ? 'text-danger' : ''}>{s.error_count}</td>
<td className={s.error_rate > 5 ? 'text-danger' : ''}>{s.error_rate}%</td>
<td>{s.avg_response_time}s</td>
<td>{fmtTime(s.last_called_at)}</td>
</tr>
))}
{callStats.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无调用统计</td></tr>}
</tbody>
</table>
{callStats.total > 20 && (
<div className="dev-pagination">
<button disabled={callStats.page <= 1} onClick={() => loadCallStats(callStats.page - 1)}>上一页</button>
<span>第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页</span>
<button disabled={callStats.page >= Math.ceil(callStats.total / 20)} onClick={() => loadCallStats(callStats.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Slow Logs Tab */}
{activeTab === 'logs' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={logSearch} onChange={e => setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} />
<button className="dev-btn danger" onClick={handleClearLogs}>清空日志</button>
</div>
<table className="dev-table">
<thead>
<tr><th>请求ID</th><th>方法</th><th>端点</th><th>状态码</th><th>响应时间</th><th>阈值</th><th>时间</th></tr>
</thead>
<tbody>
{slowLogs.items.map(l => (
<tr key={l.id}>
<td className="mono-text">{l.request_id?.slice(0, 12) || '-'}</td>
<td><span className={`method-badge method-${l.method.toLowerCase()}`}>{l.method}</span></td>
<td className="mono-text">{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}</td>
<td className={l.status_code >= 400 ? 'text-danger' : ''}>{l.status_code}</td>
<td className="text-warn">{l.response_time}s</td>
<td>{l.threshold}s</td>
<td>{fmtTime(l.created_at)}</td>
</tr>
))}
{slowLogs.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无慢请求日志</td></tr>}
</tbody>
</table>
{slowLogs.total > 20 && (
<div className="dev-pagination">
<button disabled={slowLogs.page <= 1} onClick={() => loadSlowLogs(slowLogs.page - 1)}>上一页</button>
<span>第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页</span>
<button disabled={slowLogs.page >= Math.ceil(slowLogs.total / 20)} onClick={() => loadSlowLogs(slowLogs.page + 1)}>下一页</button>
</div>
)}
</div>
)}
</div>
{/* Create API Key Modal */}
{showKeyModal && (
<div className="dev-modal-overlay" onClick={() => setShowKeyModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建API密钥</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={keyForm.user_id} onChange={e => setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" />
</div>
<div className="dev-form-group">
<label>描述</label>
<input className="dev-input" value={keyForm.description} onChange={e => setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={keyForm.rate_limit} onChange={e => setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowKeyModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateKey} disabled={!keyForm.user_id}>创建</button>
</div>
</div>
</div>
)}
{/* Create Client Modal */}
{showClientModal && (
<div className="dev-modal-overlay" onClick={() => setShowClientModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建OpenAPI客户端</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={clientForm.user_id} onChange={e => setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称 *</label>
<input className="dev-input" value={clientForm.name} onChange={e => setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" />
</div>
<div className="dev-form-group">
<label>允许的模型 (逗号分隔,留空=全部)</label>
<input className="dev-input" value={clientForm.allowed_models} onChange={e => setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={clientForm.rate_limit} onChange={e => setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowClientModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateClient} disabled={!clientForm.user_id || !clientForm.name}>创建</button>
</div>
</div>
</div>
)}
</div>
);
}
+537
View File
@@ -0,0 +1,537 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Webhook {
id: string; name: string; url: string; events: string[];
has_secret: boolean; headers: Record<string, string>; status: string;
last_triggered_at: string | null; last_status: string | null;
total_deliveries: number; success_count: number; fail_count: number;
success_rate: number; created_at: string; updated_at: string;
}
interface WebhookEvent {
value: string; label: string; desc: string;
}
interface WebhookDelivery {
id: string; event: string; status: string; response_code: number | null;
response_time_ms: number | null; attempts: number; error_message: string | null;
payload: any; response_body: string; created_at: string;
}
interface SDKPackage {
language: string; name: string; version: string; description: string;
install_cmd: string; download_url: string; github_url: string;
min_version: string; features: string[]; code_example: string;
}
interface ApiDocSection {
section: string; icon: string; description: string;
endpoints: Array<{
method: string; path: string; title: string; description: string;
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
example_request: string; example_response: string;
}>;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloperTools() {
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
const [loading, setLoading] = useState(false);
// Webhooks state
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
const [showWebhookModal, setShowWebhookModal] = useState(false);
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
const [testResult, setTestResult] = useState<any>(null);
// SDK state
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
const [selectedSdk, setSelectedSdk] = useState<string>('python');
// API Docs state
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const loadWebhooks = useCallback(async (page = 1) => {
setLoading(true);
try {
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
if (res.code === 0) setWebhooks(res.data);
} catch (e) { console.error('Webhooks error:', e); }
setLoading(false);
}, []);
const loadWebhookEvents = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/webhooks/events');
if (res.code === 0) setWebhookEvents(res.data.events);
} catch (e) { console.error('Events error:', e); }
}, []);
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
try {
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
if (res.code === 0) setDeliveries(res.data);
} catch (e) { console.error('Deliveries error:', e); }
}, []);
const loadSdkPackages = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/sdk/packages');
if (res.code === 0) setSdkPackages(res.data.packages);
} catch (e) { console.error('SDK error:', e); }
}, []);
const loadApiDocs = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/api-docs');
if (res.code === 0) setDocSections(res.data.sections);
} catch (e) { console.error('Docs error:', e); }
}, []);
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
const handleCreateWebhook = async () => {
try {
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
setShowWebhookModal(false);
setWebhookForm({ name: '', url: '', events: [] });
loadWebhooks();
if (res.data?.secret) {
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
}
} catch (e: any) { alert(e.message); }
};
const handleDeleteWebhook = async (id: string) => {
if (!confirm('确定删除此 Webhook?')) return;
try {
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
loadWebhooks();
setSelectedWebhook(null);
} catch (e: any) { alert(e.message); }
};
const handleToggleWebhook = async (wh: Webhook) => {
const newStatus = wh.status === 'active' ? 'paused' : 'active';
try {
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleTestWebhook = async (id: string) => {
setTestResult(null);
try {
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
});
setTestResult(res.data);
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleViewDeliveries = (wh: Webhook) => {
setSelectedWebhook(wh);
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
loadDeliveries(wh.id);
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
const methodBadge = (method: string) => {
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard');
}).catch(() => alert('Copy failed'));
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者工具</h2>
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
</div>
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* ===== Webhooks Tab ===== */}
{activeTab === 'webhooks' && !loading && (
<div>
{selectedWebhook ? (
/* Webhook Detail / Deliveries View */
<div>
<div className="dev-toolbar">
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
&larr; Back to list
</button>
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
</div>
{testResult && (
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
<strong>Test Result: </strong>
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
</div>
)}
<table className="dev-table">
<thead>
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
</thead>
<tbody>
{deliveries.items.map(d => (
<tr key={d.id}>
<td className="mono-text">{d.event}</td>
<td>{statusBadge(d.status)}</td>
<td>{d.response_code || '-'}</td>
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
<td>{d.attempts}</td>
<td>{fmtTime(d.created_at)}</td>
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
</tr>
))}
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
</tbody>
</table>
{deliveries.total > 20 && (
<div className="dev-pagination">
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
</div>
)}
</div>
) : (
/* Webhook List View */
<div>
<div className="dev-toolbar">
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
</thead>
<tbody>
{webhooks.items.map(w => (
<tr key={w.id}>
<td>{w.name}</td>
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
<td>
{w.events.length > 0 ? (
<span>{w.events.length} events</span>
) : <span className="dev-muted">All events</span>}
</td>
<td>{statusBadge(w.status)}</td>
<td>{w.total_deliveries}</td>
<td>
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
{w.success_rate}%
</span>
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
</td>
<td>{fmtTime(w.last_triggered_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
</td>
</tr>
))}
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
</tbody>
</table>
{webhooks.total > 20 && (
<div className="dev-pagination">
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
</div>
)}
{/* 支持的事件 Info */}
{webhookEvents.length > 0 && (
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
{webhookEvents.map(ev => (
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create Webhook Modal */}
{showWebhookModal && (
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建 Webhook</h3>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
</div>
<div className="dev-form-group">
<label>URL</label>
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
</div>
<div className="dev-form-group">
<label>Events (leave empty for all)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
{webhookEvents.map(ev => (
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
}} />
{ev.label}
</label>
))}
</div>
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
</div>
</div>
</div>
)}
</div>
)}
{/* ===== SDK Download Center Tab ===== */}
{activeTab === 'sdk' && !loading && (
<div>
{/* Language Selector */}
<div className="dev-toolbar">
{sdkPackages.map(pkg => (
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
{pkg.name}
</button>
))}
</div>
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
{/* Package Info */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
</div>
</div>
{/* Install Command */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
{pkg.install_cmd}
</code>
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
</div>
</div>
{/* 功能特性 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{pkg.features.map(f => (
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
{f}
</span>
))}
</div>
</div>
{/* Code Example */}
<div>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
<div style={{ position: 'relative' }}>
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
<code>{pkg.code_example}</code>
</pre>
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
</div>
</div>
</div>
))}
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
</div>
)}
{/* ===== Interactive API Docs Tab ===== */}
{activeTab === 'docs' && !loading && (
<div>
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
</div>
{docSections.map(section => {
const isExpanded = expandedSection === section.section;
return (
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
{/* Section Header */}
<div
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '18px' }}>
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
</span>
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
</div>
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
</div>
{/* Section Content */}
{isExpanded && (
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
{section.endpoints.map(ep => {
const epKey = `${section.section}-${ep.path}`;
const isEpExpanded = expandedEndpoint === epKey;
return (
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
<div
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
>
{methodBadge(ep.method)}
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
</div>
{isEpExpanded && (
<div style={{ padding: '0 16px 16px 16px' }}>
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
{/* 参数 */}
{ep.params.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
<table className="dev-table" style={{ fontSize: '13px' }}>
<thead>
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
</thead>
<tbody>
{ep.params.map(p => (
<tr key={p.name}>
<td className="mono-text">{p.name}</td>
<td>{p.type}</td>
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
<td>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 请求示例 */}
{ep.example_request && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_request}</code>
</pre>
</div>
)}
{/* 响应示例 */}
{ep.example_response && (
<div>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_response}</code>
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,986 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface Distribution {
id: string;
name: string;
description: string;
brand_name: string;
brand_logo_url: string;
brand_color: string;
system_prompt: string;
provider: string;
model: string;
mcp_extensions: any[];
workflow_ids: string[];
knowledge_base_ids: string[];
agent_ids: string[];
features: string[];
status: string;
version: string;
download_count: number;
size_bytes: number;
license_count?: number;
created_at: string;
updated_at: string;
}
interface License {
id: string;
distribution_id: string;
distribution_name: string;
org_name: string;
licensee_email: string;
license_key: string;
features: string[];
max_users: number;
max_executions: number;
usage_count: number;
expiry_date: string | null;
status: string;
created_at: string;
last_used_at: string | null;
}
interface Stats {
total: number;
draft: number;
active: number;
archived: number;
total_downloads: number;
total_licenses: number;
active_licenses: number;
}
interface Templates {
workflows: any[];
agents: any[];
knowledge_bases: any[];
mcp_tools: any[];
}
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '已发布', archived: '已归档' };
const STATUS_COLORS: Record<string, string> = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' };
const FEATURE_LABELS: Record<string, string> = {
chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索',
agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook',
};
export default function DistributionBuilder() {
const [distributions, setDistributions] = useState<Distribution[]>([]);
const [stats, setStats] = useState<Stats>({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 });
const [templates, setTemplates] = useState<Templates>({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] });
const [licenses, setLicenses] = useState<License[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions');
const [showCreate, setShowCreate] = useState(false);
const [showLicense, setShowLicense] = useState(false);
const [selectedDistro, setSelectedDistro] = useState<string>('');
const [editDistro, setEditDistro] = useState<Distribution | null>(null);
const [page, setPage] = useState(1);
const [licPage, setLicPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [licTotalPages, setLicTotalPages] = useState(1);
// Form state
const [form, setForm] = useState({
name: '', description: '', brand_name: '', brand_color: '#6C63FF',
system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash',
features: ['chat'] as string[], version: '1.0.0',
workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[],
});
// License form
const [licForm, setLicForm] = useState({
org_name: '', licensee_email: '', max_users: 10, max_executions: 10000,
expiry_date: '', features: ['chat'] as string[],
});
const loadData = async () => {
setLoading(true);
try {
const [s, d, t] = await Promise.all([
adminAPI.getDistributionStats(),
adminAPI.getDistributions({ page, page_size: 20 }),
adminAPI.getDistributionTemplates(),
]);
if (s.code === 0) setStats(s.data);
if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); }
if (t.code === 0) setTemplates(t.data);
} catch (e) { console.error(e); }
setLoading(false);
};
const loadLicenses = async () => {
try {
const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 });
if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); }
} catch (e) { console.error(e); }
};
useEffect(() => { loadData(); }, [page]);
useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]);
const handleCreate = async () => {
try {
const res = await adminAPI.createDistribution(form);
if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); }
else alert('创建失败: ' + (res.message || ''));
} catch (e) { alert('创建失败'); }
};
const handleUpdate = async () => {
if (!editDistro) return;
try {
const res = await adminAPI.updateDistribution(editDistro.id, form);
if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); }
else alert('更新失败');
} catch (e) { alert('更新失败'); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return;
try {
await adminAPI.deleteDistribution(id);
loadData();
} catch (e) { alert('删除失败'); }
};
const handleBuild = async (id: string) => {
try {
const token = localStorage.getItem('token') || '';
window.open(`/app/api/admin/distributions/${id}/build`, '_blank');
} catch (e) { alert('下载失败'); }
};
const handleGenerateLicense = async () => {
if (!selectedDistro) return;
try {
const res = await adminAPI.createDistributionLicense(selectedDistro, licForm);
if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); }
else alert('生成失败: ' + (res.message || ''));
} catch (e) { alert('生成失败'); }
};
const handleRevokeLicense = async (id: string) => {
if (!confirm('确定吊销此许可证?')) return;
try {
await adminAPI.revokeDistributionLicense(id);
loadLicenses();
} catch (e) { alert('吊销失败'); }
};
const resetForm = () => {
setForm({ name: '', description: '', brand_name: '', brand_color: '#6C63FF', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] });
};
const openEdit = (d: Distribution) => {
setEditDistro(d);
setForm({
name: d.name, description: d.description || '', brand_name: d.brand_name || '',
brand_color: d.brand_color || '#6C63FF', system_prompt: d.system_prompt || '',
provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash',
features: d.features || ['chat'], version: d.version || '1.0.0',
workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [],
});
};
const toggleFeature = (feat: string) => {
setForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const toggleLicFeature = (feat: string) => {
setLicForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const isEditing = !!editDistro;
// Styles
const dark = { accent: '#6C63FF', red: '#EF4444', green: '#10B981', yellow: '#F59E0B', bg: '#F5F7FC', card: '#FFFFFF', cardBg: '#FFFFFF', border: 'rgba(0,0,0,0.08)', text: '#1A1F36', sub: '#8892B0', textSecondary: '#5A6480', textTertiary: '#8892B0' };
const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 12, padding: 20 };
const btnPrimary: React.CSSProperties = { background: '#6C63FF', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnDanger: React.CSSProperties = { background: '#EF4444', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnGhost: React.CSSProperties = { background: 'transparent', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 };
const inputStyle: React.CSSProperties = { background: '#FFFFFF', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' };
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' };
const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' };
return (
<div style={{ padding: 24, background: '#FFFFFF', minHeight: '100vh', color: '#1A1F36', fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>发行版构建器</h1>
<p style={{ margin: '4px 0 0', color: dark.sub, fontSize: 13 }}>SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理</p>
</div>
<button onClick={() => { setShowCreate(true); setEditDistro(null); resetForm(); }} style={btnPrimary}>
+ 新建发行版
</button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[
{ label: '发行版总数', value: stats.total },
{ label: '已发布', value: stats.active, color: dark.green },
{ label: '累计下载', value: stats.total_downloads, color: '#6C63FF' },
{ label: '有效许可证', value: stats.active_licenses, color: dark.yellow },
].map((s, i) => (
<div key={i} style={cardStyle}>
<div style={{ color: dark.sub, fontSize: 13 }}>{s.label}</div>
<div style={{ fontSize: 32, fontWeight: 700, color: s.color || '#1A1F36', marginTop: 4 }}>{s.value}</div>
</div>
))}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${'rgba(0,0,0,0.08)'}` }}>
{[
{ key: 'distributions', label: '发行版管理' },
{ key: 'licenses', label: '许可证管理' },
].map(t => (
<button key={t.key}
onClick={() => setTab(t.key as any)}
style={{
padding: '10px 24px', background: 'transparent', border: 'none',
color: tab === t.key ? '#6C63FF' : dark.sub,
borderBottom: tab === t.key ? `2px solid ${'#6C63FF'}` : '2px solid transparent',
cursor: 'pointer', fontSize: 15, fontWeight: tab === t.key ? 600 : 400,
}}>
{t.label}
</button>
))}
</div>
{/* Distributions Tab */}
{tab === 'distributions' && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{distributions.map(d => (
<div key={d.id} style={{ ...cardStyle, display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 48, height: 48, borderRadius: 10, background: d.brand_color || '#6C63FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
{(d.brand_name || d.name)[0]}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16, fontWeight: 600 }}>{d.name}</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: STATUS_COLORS[d.status] + '22', color: STATUS_COLORS[d.status], fontWeight: 600 }}>
{STATUS_LABELS[d.status] || d.status}
</span>
<span style={{ color: dark.sub, fontSize: 12 }}>v{d.version}</span>
</div>
<div style={{ color: dark.sub, fontSize: 13, marginTop: 2 }}>{d.description || '无描述'}</div>
<div style={{ display: 'flex', gap: 12, marginTop: 6, fontSize: 12, color: dark.sub }}>
{d.features?.map(f => (
<span key={f}>✓ {FEATURE_LABELS[f] || f}</span>
))}
<span>下载 {d.download_count || 0} 次</span>
<span>{d.license_count || 0} 个许可证</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => handleBuild(d.id)} style={btnGhost}>下载配置包</button>
<button onClick={() => { setSelectedDistro(d.id); setShowLicense(true); }} style={btnGhost}>生成许可证</button>
<button onClick={() => openEdit(d)} style={btnGhost}>编辑</button>
<button onClick={() => handleDelete(d.id)} style={btnDanger}>删除</button>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {page}/{totalPages} 页</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Licenses Tab */}
{tab === 'licenses' && (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button onClick={() => setShowLicense(true)} style={btnPrimary}>+ 生成许可证</button>
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
{['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', color: dark.sub, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.id} style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
<td style={{ padding: '10px 12px', fontFamily: 'monospace', fontSize: 12, color: '#6C63FF' }}>{lic.license_key}</td>
<td style={{ padding: '10px 12px' }}>{lic.org_name}</td>
<td style={{ padding: '10px 12px' }}>{lic.distribution_name}</td>
<td style={{ padding: '10px 12px', color: dark.sub }}>{lic.usage_count}/{lic.max_executions}</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: lic.status === 'active' ? '#3fb95022' : '#f8514922', color: lic.status === 'active' ? dark.green : '#EF4444' }}>{lic.status}</span>
</td>
<td style={{ padding: '10px 12px', color: dark.sub, fontSize: 12 }}>{lic.expiry_date || '永久'}</td>
<td style={{ padding: '10px 12px' }}>
{lic.status === 'active' && (
<button onClick={() => handleRevokeLicense(lic.id)} style={{ ...btnDanger, padding: '4px 12px', fontSize: 12 }}>吊销</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{licTotalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setLicPage(p => Math.max(1, p - 1))} disabled={licPage <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {licPage}/{licTotalPages} 页</span>
<button onClick={() => setLicPage(p => Math.min(licTotalPages, p + 1))} disabled={licPage >= licTotalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Create/Edit Modal */}
{(showCreate || isEditing) && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 640, maxHeight: '85vh', overflow: 'auto', padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>{isEditing ? '编辑发行版' : '新建发行版'}</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>名称 *</label>
<input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>描述</label>
<textarea style={textareaStyle} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="发行版描述..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>品牌名称</label>
<input style={inputStyle} value={form.brand_name} onChange={e => setForm({ ...form, brand_name: e.target.value })} placeholder="例如: MyAI" />
</div>
<div>
<label style={labelStyle}>品牌主色</label>
<input type="color" style={{ ...inputStyle, height: 38, padding: 4, width: 60 }} value={form.brand_color} onChange={e => setForm({ ...form, brand_color: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>系统提示词</label>
<textarea style={textareaStyle} value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} placeholder="You are a helpful AI assistant..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>LLM 提供商</label>
<select style={inputStyle} value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="siliconflow">SiliconFlow</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div>
<label style={labelStyle}>默认模型</label>
<input style={inputStyle} value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>版本号</label>
<input style={inputStyle} value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: form.features.includes(key) ? '#6C63FF' : 'transparent',
color: form.features.includes(key) ? '#fff' : dark.sub,
border: form.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowCreate(false); setEditDistro(null); }} style={btnGhost}>取消</button>
<button onClick={isEditing ? handleUpdate : handleCreate} style={btnPrimary}>{isEditing ? '保存修改' : '创建'}</button>
</div>
</div>
</div>
)}
{/* License Modal */}
{showLicense && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 500, padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>生成许可证</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>发行版 *</label>
<select style={inputStyle} value={selectedDistro} onChange={e => setSelectedDistro(e.target.value)}>
<option value="">-- 选择发行版 --</option>
{distributions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>组织名称 *</label>
<input style={inputStyle} value={licForm.org_name} onChange={e => setLicForm({ ...licForm, org_name: e.target.value })} placeholder="例如: Acme Corp" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>被授权邮箱</label>
<input style={inputStyle} value={licForm.licensee_email} onChange={e => setLicForm({ ...licForm, licensee_email: e.target.value })} placeholder="admin@example.com" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>最大用户数</label>
<input type="number" style={inputStyle} value={licForm.max_users} onChange={e => setLicForm({ ...licForm, max_users: Number(e.target.value) })} />
</div>
<div>
<label style={labelStyle}>最大执行次数</label>
<input type="number" style={inputStyle} value={licForm.max_executions} onChange={e => setLicForm({ ...licForm, max_executions: Number(e.target.value) })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>过期日期 (留空为永久)</label>
<input type="date" style={inputStyle} value={licForm.expiry_date} onChange={e => setLicForm({ ...licForm, expiry_date: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>授权功能</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleLicFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: licForm.features.includes(key) ? '#6C63FF' : 'transparent',
color: licForm.features.includes(key) ? '#fff' : dark.sub,
border: licForm.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => setShowLicense(false)} style={btnGhost}>取消</button>
<button onClick={handleGenerateLicense} style={btnPrimary}>生成</button>
</div>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+538
View File
@@ -0,0 +1,538 @@
import React, { useState, useEffect, useRef } from 'react';
import { adminAPI } from '@/api';
interface KnowledgeBaseItem {
id: string;
name: string;
user_id: string;
owner_name?: string;
description?: string;
directory_path: string;
document_count: number;
knowledge_graph_count: number;
is_official: boolean;
status: string;
external_service_id?: string;
external_id?: string;
external_sync_status: string;
external_config?: any;
total_storage_bytes?: number;
total_storage_mb?: number;
created_at: string;
updated_at: string;
doc_stats?: Record<string, { count: number; size: number }>;
graph_stats?: { nodes: number; edges: number };
}
interface ExternalService {
id: string; name: string; service_type: string; api_url: string;
api_key?: string; config?: any; is_enabled: boolean; status: string;
created_at?: string; updated_at?: string;
}
interface Stats {
total_knowledge_bases: number; total_documents: number;
total_storage_bytes: number; total_storage_display: string;
official_kb: number; user_kb: number; active_kb: number;
kb_with_graph: number; external_kb: number; recent_kb_7d: number;
total_graph_nodes: number; total_graph_edges: number;
doc_status_distribution: Record<string, number>;
file_type_distribution: Record<string, number>;
}
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const formatSize = (bytes: number) => {
if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
};
const Knowledge: React.FC = () => {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(15);
const [keyword, setKeyword] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterOfficial, setFilterOfficial] = useState('');
// External Services
const [extServices, setExtServices] = useState<ExternalService[]>([]);
const [showExtModal, setShowExtModal] = useState(false);
const [extForm, setExtForm] = useState({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' });
// KB Modal
const [showModal, setShowModal] = useState(false);
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
const [kbForm, setKBForm] = useState({ name: '', description: '', is_official: false });
// Detail drawer
const [showDetail, setShowDetail] = useState(false);
const [selectedKB, setSelectedKB] = useState<KnowledgeBaseItem | null>(null);
const [detailDocs, setDetailDocs] = useState<any[]>([]);
const [detailDocTotal, setDetailDocTotal] = useState(0);
// Document preview
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
const [previewContent, setPreviewContent] = useState<string>('');
// File upload
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchStats(); fetchKBs(); fetchExtServices();
}, []);
useEffect(() => { fetchKBs(); }, [page, keyword, filterStatus, filterOfficial]);
const fetchStats = async () => {
try { const res = await adminAPI.getAdminKnowledgeStats(); if (res.code === 0) setStats(res.data); } catch (e) {}
};
const fetchKBs = async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (keyword) params.keyword = keyword;
if (filterStatus) params.status = filterStatus;
if (filterOfficial) params.is_official = filterOfficial === 'true';
const res = await adminAPI.getAdminKnowledge(params);
if (res.code === 0) {
setKnowledgeBases(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (e) {}
setLoading(false);
};
const fetchExtServices = async () => {
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
};
const handleCreateKB = async () => {
try {
const res = await adminAPI.createAdminKnowledge(kbForm);
if (res.code === 0) { setShowModal(false); setKBForm({ name: '', description: '', is_official: false }); fetchKBs(); fetchStats(); }
} catch (e) {}
};
const handleUpdateKB = async () => {
if (!editingKB) return;
try {
const res = await adminAPI.updateAdminKnowledge(editingKB.id, kbForm);
if (res.code === 0) { setShowModal(false); fetchKBs(); fetchStats(); }
} catch (e) {}
};
const handleDeleteKB = async (id: string) => {
if (!confirm('确认删除该知识库及其所有文档?此操作不可恢复!')) return;
try { const res = await adminAPI.deleteAdminKnowledge(id); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
};
const handleToggleKB = async (id: string, isActive: boolean) => {
try { const res = await adminAPI.toggleAdminKnowledge(id, isActive); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
};
const handleViewDetail = async (kb: KnowledgeBaseItem) => {
setSelectedKB(kb); setShowDetail(true);
try {
const res = await adminAPI.getAdminKnowledgeDetail(kb.id);
if (res.code === 0) setSelectedKB(res.data);
const docRes = await adminAPI.getAdminKnowledgeDocs(kb.id, { page: 1, page_size: 50 });
if (docRes.code === 0) { setDetailDocs(docRes.data.items || []); setDetailDocTotal(docRes.data.total || 0); }
} catch (e) {}
};
// Document upload
const handleFileUpload = async (kbId: string, file: File) => {
setUploading(true);
setUploadMsg(`正在上传 ${file.name}...`);
try {
const formData = new FormData();
formData.append('file', file);
// Use raw fetch since adminAPI may not support FormData
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
const data = await res.json();
if (data.code === 0) {
setUploadMsg(`✅ ${file.name} 上传成功!`);
handleViewDetail(selectedKB!);
fetchStats();
} else {
setUploadMsg(`❌ 上传失败: ${data.message || '未知错误'}`);
}
} catch (e: any) {
setUploadMsg(`❌ 上传失败: ${e.message || '网络错误'}`);
} finally {
setUploading(false);
setTimeout(() => setUploadMsg(''), 5000);
}
};
// Document preview
const handlePreviewDoc = async (kbId: string, doc: any) => {
setPreviewDoc(doc);
setPreviewContent('加载中...');
try {
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/${doc.id}/content`, {
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json();
if (data.code === 0) {
setPreviewContent(data.data?.content || data.data?.text || '(文档内容为空)');
} else {
setPreviewContent(data.message || '无法加载文档内容');
}
} catch (e: any) {
setPreviewContent(`加载失败: ${e.message}`);
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
if (!confirm('确认删除该文档?')) return;
try { const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId); if (res.code === 0) { handleViewDetail(selectedKB!); fetchStats(); } } catch (e) {}
};
const handleCreateExtService = async () => {
try { const res = await adminAPI.createAdminKnowledgeExtService(extForm); if (res.code === 0) { setShowExtModal(false); setExtForm({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' }); fetchExtServices(); } } catch (e) {}
};
const handleDeleteExtService = async (id: string) => {
if (!confirm('确认删除该外部知识库服务配置?')) return;
try { const res = await adminAPI.deleteAdminKnowledgeExtService(id); if (res.code === 0) fetchExtServices(); } catch (e) {}
};
const handleCheckExtStatus = async (id: string) => {
try { const res = await adminAPI.checkAdminKnowledgeExtStatus(id); if (res.code === 0) { alert(`连接状态: ${res.data.connected ? '已连接' : '未连接'}\n详情: ${res.data.detail || '无'}`); fetchExtServices(); } } catch (e) {}
};
const statusBadge = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
active: { color: '#16A34A', text: '活跃' }, inactive: { color: '#6B7280', text: '禁用' },
archived: { color: '#F59E0B', text: '归档' }, published: { color: '#16A34A', text: '已发布' },
pending: { color: '#F59E0B', text: '待审核' }, completed: { color: '#16A34A', text: '完成' },
processing: { color: '#3B82F6', text: '处理中' }, failed: { color: '#DC2626', text: '失败' },
uploading: { color: '#3B82F6', text: '上传中' },
};
const s = map[status] || { color: '#6B7280', text: status };
return <span style={{ fontSize: 12, padding: '2px 8px', background: s.color + '18', color: s.color, borderRadius: 4, fontWeight: 500 }}>{s.text}</span>;
};
const syncStatusBadge = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
none: { color: '#9CA3AF', text: '未同步' }, syncing: { color: '#3B82F6', text: '同步中' },
synced: { color: '#16A34A', text: '已同步' }, error: { color: '#DC2626', text: '同步错误' },
};
const s = map[status] || { color: '#9CA3AF', text: status };
return <span style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</span>;
};
const totalPages = Math.ceil(total / pageSize);
const statCardDefs = stats ? [
{ label: '知识库总数', value: stats.total_knowledge_bases, color: '#6C63FF' },
{ label: '文档总数', value: stats.total_documents, color: '#219B5E' },
{ label: '存储占用', value: stats.total_storage_display, color: '#E8922C' },
{ label: '官方知识库', value: stats.official_kb, color: '#8B5CF6' },
{ label: '含图谱KB', value: stats.kb_with_graph, color: '#3B82F6' },
{ label: '外部集成', value: stats.external_kb, color: '#EC4899' },
] : [];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">知识库管理</h1>
{/* Stats */}
{stats && (
<div className="admin-stats" style={{ marginBottom: 'var(--space-xl)' }}>
{statCardDefs.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color, fontFamily: 'var(--font-mono)' }}>
{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 4 }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Toolbar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<input type="text" placeholder="搜索知识库..." value={keyword} onChange={e => setKeyword(e.target.value)}
style={inputStyle} />
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} style={selectStyle}>
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="inactive">禁用</option>
</select>
<select value={filterOfficial} onChange={e => setFilterOfficial(e.target.value)} style={selectStyle}>
<option value="">全部来源</option>
<option value="true">官方</option>
<option value="false">用户</option>
</select>
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
</div>
{/* Table */}
<div className="admin-card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead>
<tr>
<th>名称</th><th>来源</th><th>文档数</th><th>存储</th><th>状态</th><th>同步</th><th>创建时间</th><th style={{ textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : knowledgeBases.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40, color: '#9CA3AF' }}>暂无知识库数据</td></tr>
) : knowledgeBases.map(kb => (
<tr key={kb.id}>
<td>
<div style={{ fontWeight: 600 }}>{kb.name}</div>
{kb.description && <div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 2 }}>{kb.description.substring(0, 60)}</div>}
</td>
<td>{kb.is_official ? <span style={{ color: '#8B5CF6', fontWeight: 600 }}>官方</span> : <span style={{ color: '#6B7280' }}>{kb.owner_name || '用户'}</span>}</td>
<td style={{ fontWeight: 600 }}>{kb.document_count}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{kb.total_storage_mb || 0} MB</td>
<td>{statusBadge(kb.status)}</td>
<td>{syncStatusBadge(kb.external_sync_status)}</td>
<td style={{ fontSize: 12, color: '#9CA3AF' }}>{kb.created_at?.substring(0, 10)}</td>
<td style={{ textAlign: 'center' }}>
<button onClick={() => handleViewDetail(kb)} style={btnStyle('#3B82F6', 'small')}>详情</button>
<button onClick={() => { setEditingKB(kb); setKBForm({ name: kb.name, description: kb.description || '', is_official: kb.is_official }); setShowModal(true); }} style={{ ...btnStyle('#F59E0B', 'small'), marginLeft: 4 }}>编辑</button>
<button onClick={() => handleToggleKB(kb.id, kb.status !== 'active')} style={{ ...btnStyle(kb.status === 'active' ? '#DC2626' : '#16A34A', 'small'), marginLeft: 4 }}>{kb.status === 'active' ? '禁用' : '启用'}</button>
<button onClick={() => handleDeleteKB(kb.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={btnStyle(page <= 1 ? '#D1D5DB' : '#6C63FF', 'small')}>上一页</button>
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page}/{totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={btnStyle(page >= totalPages ? '#D1D5DB' : '#6C63FF', 'small')}>下一页</button>
</div>
)}
{/* KB Modal */}
{showModal && (
<div style={overlayStyle} onClick={() => setShowModal(false)}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
<h3>{editingKB ? '编辑知识库' : '创建知识库'}</h3>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>名称</label>
<input value={kbForm.name} onChange={e => setKBForm({ ...kbForm, name: e.target.value })} style={fullInput} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>描述</label>
<textarea value={kbForm.description} onChange={e => setKBForm({ ...kbForm, description: e.target.value })} rows={3} style={fullInput} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={kbForm.is_official} onChange={e => setKBForm({ ...kbForm, is_official: e.target.checked })} />
官方平台知识库
</label>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={editingKB ? handleUpdateKB : handleCreateKB} style={btnStyle('#6C63FF')}>{editingKB ? '保存' : '创建'}</button>
<button onClick={() => setShowModal(false)} style={btnStyle('#6B7280')}>取消</button>
</div>
</div>
</div>
)}
{/* Detail Drawer */}
{showDetail && selectedKB && (
<div style={overlayStyle} onClick={() => setShowDetail(false)}>
<div style={{ ...drawerStyle }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>{selectedKB.name}</h3>
<button onClick={() => setShowDetail(false)} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
</div>
<div style={{ marginBottom: 16, padding: 12, background: '#F9FAFB', borderRadius: 8, fontSize: 13 }}>
<div>来源: {selectedKB.is_official ? '官方' : selectedKB.owner_name || '用户'}</div>
{selectedKB.description && <div style={{ marginTop: 4 }}>描述: {selectedKB.description}</div>}
<div style={{ marginTop: 4 }}>状态: {statusBadge(selectedKB.status)} | 文档: {selectedKB.document_count} | 存储: {selectedKB.total_storage_mb || 0} MB</div>
</div>
{/* Upload */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="file" ref={fileInputRef} style={{ display: 'none' }}
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(selectedKB.id, f); e.target.value = ''; }}
accept=".pdf,.docx,.doc,.txt,.md,.csv,.json,.xml" />
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
style={btnStyle(uploading ? '#D1D5DB' : '#219B5E')}>
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
{' '}{uploading ? '上传中...' : '上传文档'}
</button>
<span style={{ fontSize: 12, color: '#6B7280' }}>支持 PDF / DOCX / TXT / MD / CSV / JSON</span>
</div>
{uploadMsg && (
<div style={{ marginTop: 8, fontSize: 13, color: uploadMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: uploadMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
{uploadMsg}
</div>
)}
</div>
{/* Graph Stats */}
{selectedKB.graph_stats && (selectedKB.graph_stats.nodes > 0 || selectedKB.graph_stats.edges > 0) && (
<div style={{ marginBottom: 16, padding: 12, background: '#EEF2FF', borderRadius: 8 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>知识图谱</h4>
<div style={{ fontSize: 13 }}>节点数: {selectedKB.graph_stats.nodes} | 边数: {selectedKB.graph_stats.edges}</div>
</div>
)}
{/* External Integration */}
{selectedKB.external_service_id && (
<div style={{ marginBottom: 16, padding: 12, background: '#F5F3FF', borderRadius: 8, fontSize: 13 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>外部集成</h4>
<div>同步状态: {syncStatusBadge(selectedKB.external_sync_status)}</div>
<div>外部ID: {selectedKB.external_id || '未关联'}</div>
</div>
)}
{/* Documents */}
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>文档列表 ({detailDocTotal})</h4>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead>
<tr>
<th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th style={{ textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{detailDocs.map(doc => (
<tr key={doc.id}>
<td style={{ fontSize: 12 }}>{doc.filename || doc.file_name}</td>
<td style={{ fontSize: 11 }}><span style={{ padding: '1px 6px', background: '#F3F4F6', borderRadius: 3 }}>{doc.file_type || '-'}</span></td>
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatSize(doc.file_size || 0)}</td>
<td>{statusBadge(doc.status)}</td>
<td style={{ textAlign: 'center' }}>
<button onClick={() => handlePreviewDoc(selectedKB.id, doc)} style={btnStyle('#3B82F6', 'small')}>预览</button>
<button onClick={() => handleDeleteDoc(selectedKB.id, doc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
{detailDocs.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#9CA3AF' }}>暂无文档,请上传</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Document Preview Modal */}
{previewDoc && (
<div style={overlayStyle} onClick={() => { setPreviewDoc(null); setPreviewContent(''); }}>
<div style={{ ...drawerStyle, maxWidth: '80vw' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>{previewDoc.filename || previewDoc.file_name}</h3>
<button onClick={() => { setPreviewDoc(null); setPreviewContent(''); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
</div>
<div style={{ fontSize: 12, color: '#6B7280', marginBottom: 12 }}>
类型: {previewDoc.file_type || '-'} | 大小: {formatSize(previewDoc.file_size || 0)}
</div>
<div style={{
maxHeight: '60vh', overflowY: 'auto', background: '#F9FAFB',
borderRadius: 8, padding: 16, fontSize: 13, lineHeight: 1.7,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'var(--font-mono)', color: '#374151',
border: '1px solid #E5E7EB',
}}>
{previewContent}
</div>
</div>
</div>
)}
{/* External Service Modal */}
{showExtModal && (
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
<h3>外部知识库服务配置</h3>
{extServices.length > 0 && (
<div style={{ marginBottom: 16 }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{extServices.map(svc => (
<tr key={svc.id}>
<td>{svc.name}</td>
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>{svc.service_type === 'fastgpt' ? 'FastGPT' : svc.service_type === 'dify' ? 'Dify' : 'RAGFlow'}</span></td>
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td>
<td>{statusBadge(svc.status)}</td>
<td>
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>添加新服务</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 FastGPT" style={fullInput} /></div>
<div><label style={labelStyle}>类型</label><select value={extForm.service_type} onChange={e => setExtForm({ ...extForm, service_type: e.target.value })} style={fullInput}><option value="fastgpt">FastGPT</option><option value="dify">Dify</option><option value="ragflow">RAGFlow</option></select></div>
</div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://your-instance.com/api/v1" style={fullInput} /></div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="Bearer Token 或 API Key" style={fullInput} /></div>
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>添加服务</button>
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
// Shared styles
const inputStyle: React.CSSProperties = { padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, width: 200, boxSizing: 'border-box' };
const selectStyle: React.CSSProperties = { padding: '8px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' };
const fullInput: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' };
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' };
const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const modalStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 500, maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto' };
const drawerStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 560, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' };
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
background: color, color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
fontSize: size === 'small' ? 12 : 14, padding: size === 'small' ? '4px 10px' : '8px 16px',
fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap',
});
export default Knowledge;
+405
View File
@@ -0,0 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const styles: Record<string, React.CSSProperties> = {
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' },
formGroup: { marginBottom: 16 },
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
detailsItem: { fontSize: 13 },
detailsLabel: { color: '#6b7280', marginBottom: 4 },
};
const TRANSPORT_TYPES = [
{ value: '', label: '全部类型' },
{ value: 'stdio', label: 'STDIO' },
{ value: 'sse', label: 'SSE' },
];
const STATUS_TYPES = [
{ value: '', label: '全部状态' },
{ value: 'connected', label: '已连接' },
{ value: 'disconnected', label: '未连接' },
{ value: 'error', label: '错误' },
];
interface MCPServer {
id: string;
name: string;
description: string;
transport_type: string;
transport_type_label: string;
command: string;
args: Record<string, any>;
env_vars: Record<string, string>;
url: string;
headers: Record<string, string>;
is_enabled: boolean;
status: string;
last_connected_at: string;
tools_count: number;
resources_count: number;
created_at: string;
updated_at: string;
}
export default function AdminMcp() {
const [servers, setServers] = useState<MCPServer[]>([]);
const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filterTransport, setFilterTransport] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
const pageSize = 20;
const showToast = (message: string, type: string = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchStats = useCallback(async () => {
try {
const result = await adminAPI.getAdminMcpStats() as any;
if (result.code === 0) setStats(result.data);
} catch (e) { /* ignore */ }
}, []);
const fetchServers = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (search) params.search = search;
if (filterTransport) params.transport_type = filterTransport;
if (filterStatus) params.status = filterStatus;
const result = await adminAPI.getAdminMcpServers(params) as any;
if (result.code === 0) {
setServers(result.data.items || []);
setTotal(result.data.total || 0);
}
} catch (e) {
showToast('加载服务失败', 'error');
} finally {
setLoading(false);
}
}, [page, search, filterTransport, filterStatus]);
useEffect(() => {
fetchStats();
fetchServers();
}, [fetchStats, fetchServers]);
const handleCreate = () => {
setEditingServer(null);
setShowModal(true);
};
const handleEdit = (server: MCPServer) => {
setEditingServer(server);
setShowModal(true);
};
const handleDelete = async (server: MCPServer) => {
if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
try {
await adminAPI.deleteAdminMcpServer(server.id);
showToast('服务已删除');
fetchServers();
fetchStats();
} catch (e) {
showToast('删除失败', 'error');
}
};
const handleToggle = async (server: MCPServer) => {
try {
await adminAPI.toggleAdminMcpServer(server.id);
showToast(server.is_enabled ? '已禁用' : '已启用');
fetchServers();
fetchStats();
} catch (e) {
showToast('切换失败', 'error');
}
};
const handleTest = async (server: MCPServer) => {
try {
const result = await adminAPI.testAdminMcpServer(server.id) as any;
if (result.code === 0) {
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
fetchServers();
fetchStats();
}
} catch (e) {
showToast('测试失败', 'error');
}
};
const handleSubmit = async (formData: any) => {
try {
if (editingServer) {
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
showToast('服务已更新');
} else {
await adminAPI.createAdminMcpServer(formData);
showToast('服务已创建');
}
setShowModal(false);
fetchServers();
fetchStats();
} catch (e: any) {
showToast(e.message || '操作失败', 'error');
}
};
const getStatusBadge = (status: string) => {
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
const colors: Record<string, { bg: string; color: string }> = {
connected: { bg: '#dcfce7', color: '#166534' },
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
error: { bg: '#fee2e2', color: '#991b1b' },
};
const c = colors[status] || colors.disconnected;
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
};
const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div style={styles.container}>
{toast && (
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
{toast.message}
</div>
)}
<div style={styles.header}>
<h1 style={styles.title}>MCP 服务管理</h1>
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
</div>
<div style={styles.statsRow}>
<div style={styles.statCard}>
<div style={styles.statLabel}>服务总数</div>
<div style={styles.statValue}>{stats.total_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已启用</div>
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已连接</div>
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>传输方式分布</div>
<div style={{ fontSize: 13, color: '#374151' }}>
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
))}
</div>
</div>
</div>
<div style={styles.toolbar}>
<input
style={styles.searchInput}
placeholder="搜索服务..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
/>
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>名称</th>
<th style={styles.th}>传输方式</th>
<th style={styles.th}>状态</th>
<th style={styles.th}>工具数</th>
<th style={styles.th}>最后连接</th>
<th style={styles.th}>启用</th>
<th style={styles.th}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : servers.length === 0 ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
) : servers.map(s => (
<tr key={s.id}>
<td style={styles.td}>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
</td>
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
<td style={styles.td}>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
{s.is_enabled ? '是' : '否'}
</span>
</td>
<td style={styles.td}>
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
{s.is_enabled ? '禁用' : '启用'}
</button>
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div style={styles.pagination}>
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
</div>
);
}
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
const [form, setForm] = useState({
name: server?.name || '',
description: server?.description || '',
transport_type: server?.transport_type || 'stdio',
command: server?.command || '',
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
url: server?.url || '',
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
});
const handleChange = (field: string, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = {
name: form.name,
description: form.description,
transport_type: form.transport_type,
};
if (form.transport_type === 'stdio') {
data.command = form.command;
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
} else {
data.url = form.url;
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
}
onSubmit(data);
};
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label style={styles.label}>名称 *</label>
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>描述</label>
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>传输类型</label>
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
<option value="stdio">STDIO(命令行方式)</option>
<option value="sse">SSE(HTTP 方式)</option>
</select>
</div>
{form.transport_type === 'stdio' ? (
<>
<div style={styles.formGroup}>
<label style={styles.label}>命令</label>
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>参数(JSON)</label>
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>环境变量(JSON)</label>
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
</div>
</>
) : (
<>
<div style={styles.formGroup}>
<label style={styles.label}>SSE 地址</label>
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>请求头(JSON)</label>
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
</>
)}
<div style={styles.modalActions}>
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
+729
View File
@@ -0,0 +1,729 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ============================================================
// Types
// ============================================================
interface McpTool {
id: string;
app_id: string;
name: string;
description: string | null;
icon: string | null;
category: string;
tags: string | null;
status: string;
publisher_name: string | null;
price: number;
is_member_free: boolean;
featured: boolean;
rating_avg: number;
install_count: number;
version: string;
server_type: string;
runtime: string | null;
command: string | null;
is_managed: boolean;
security_status: string;
security_issues: number;
deep_link: string | null;
package_name: string | null;
package_registry: string | null;
tools_count: number;
created_at: string | null;
}
interface McpToolDetail extends McpTool {
app: {
name: string;
description: string | null;
category: string;
status: string;
tutorial: string | null;
};
args: string[] | null;
env_vars: Record<string, string> | null;
transport_url: string | null;
builtin_name: string | null;
managed_url: string | null;
managed_port: number | null;
package_version: string;
package_repo_url: string | null;
security_report: Record<string, any> | null;
security_scanned_at: string | null;
permissions: Record<string, any> | null;
config_schema: Record<string, any> | null;
install_instructions: string | null;
tools_list: string[] | null;
author_name: string | null;
license: string;
homepage_url: string | null;
}
interface McpStats {
total: number;
published: number;
pending_review: number;
managed_servers: number;
by_security_status: Record<string, number>;
by_server_type: Record<string, number>;
by_category: Record<string, number>;
}
const SERVER_TYPE_LABELS: Record<string, string> = {
stdio: 'STDIO',
sse: 'SSE',
streamable_http: 'HTTP',
builtin: '内置',
};
const SECURITY_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: '#d29922' },
scanning: { label: '扫描中', color: '#58a6ff' },
approved: { label: '已通过', color: '#3fb950' },
rejected: { label: '已拒绝', color: '#f85149' },
failed: { label: '扫描失败', color: '#f85149' },
};
const MCP_CATEGORY_LABELS: Record<string, string> = {
developer: '开发工具', database: '数据库', search: '搜索检索',
file: '文件系统', browser: '浏览器', design: '设计创意',
data: '数据处理', communication: '通信协作', cloud: '云服务',
productivity: '效率工具', other: '其他',
};
// ============================================================
// Main Component
// ============================================================
const McpMarketTab: React.FC = () => {
const [tools, setTools] = useState<McpTool[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<McpStats | null>(null);
const [keyword, setKeyword] = useState('');
const [filterServerType, setFilterServerType] = useState('');
const [filterSecurity, setFilterSecurity] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [detailTool, setDetailTool] = useState<McpToolDetail | null>(null);
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getMcpMarketStats();
if (res?.code === 0) setStats(res.data);
} catch (e) { console.error(e); }
}, []);
const loadTools = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page, page_size: 20 };
if (keyword) params.keyword = keyword;
if (filterServerType) params.server_type = filterServerType;
if (filterSecurity) params.security_status = filterSecurity;
const res = await adminAPI.getMcpTools(params);
if (res?.code === 0) {
setTools(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [page, keyword, filterServerType, filterSecurity]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { loadTools(); }, [loadTools]);
const handleSeed = async () => {
if (!confirm('确认填充 Goose 内置 MCP 工具到市场?')) return;
try {
const res = await adminAPI.seedMcpTools();
if (res?.code === 0) {
alert(res.message || '填充成功');
loadStats();
loadTools();
}
} catch (e) { alert('操作失败'); }
};
const handleScan = async (id: string) => {
try {
const res = await adminAPI.scanMcpTool(id);
if (res?.code === 0) {
alert(`扫描完成: ${res.data?.status} (${res.data?.issues_count} 个问题)`);
loadTools();
}
} catch (e) { alert('扫描失败'); }
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除 MCP 工具「${name}」?`)) return;
try {
await adminAPI.deleteMcpTool(id);
loadTools();
loadStats();
} catch (e) { alert('删除失败'); }
};
const handleViewDetail = async (id: string) => {
try {
const res = await adminAPI.getMcpToolDetail(id);
if (res?.code === 0) setDetailTool(res.data);
} catch (e) { alert('加载详情失败'); }
};
const copyDeepLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
alert('Deep Link 已复制到剪贴板!');
}).catch(() => {
prompt('请手动复制:', link);
});
};
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '20px' }}>
<StatCard label="MCP 工具总数" value={stats.total} sub={`${stats.published} 已发布`} color="var(--color-primary, #58a6ff)" />
<StatCard label="待审核" value={stats.pending_review} sub="安全审核中" color="#d29922" />
<StatCard label="托管服务器" value={stats.managed_servers} sub="平台托管" color="#3fb950" />
<StatCard label="已通过审核" value={stats.by_security_status?.approved || 0} sub="可发布" color="#bc8cff" />
</div>
)}
{/* Toolbar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索 MCP 工具..."
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{
flex: 1, minWidth: 200, padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8, color: 'var(--color-text-primary, #e6edf3)', fontSize: 14,
}}
/>
<select
value={filterServerType}
onChange={(e) => { setFilterServerType(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部类型</option>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={filterSecurity}
onChange={(e) => { setFilterSecurity(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部状态</option>
{Object.entries(SECURITY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<button onClick={handleSeed} style={btnSecondaryStyle}>填充内置工具</button>
<button onClick={() => setShowCreate(true)} style={btnPrimaryStyle}>+ 创建 MCP 工具</button>
</div>
{/* Tools List */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>加载中...</div>
) : tools.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
暂无 MCP 工具。点击"填充内置工具"快速导入 Goose 内置扩展。
</div>
) : (
<div style={{ display: 'grid', gap: '12px' }}>
{tools.map(tool => (
<ToolCard
key={tool.id}
tool={tool}
onView={() => handleViewDetail(tool.id)}
onScan={() => handleScan(tool.id)}
onDelete={() => handleDelete(tool.id, tool.name)}
onCopyLink={() => tool.deep_link && copyDeepLink(tool.deep_link)}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={pageBtnStyle}>上一页</button>
<span style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={pageBtnStyle}>下一页</button>
</div>
)}
{/* Create Modal */}
{showCreate && (
<CreateMcpToolModal
onClose={() => setShowCreate(false)}
onSaved={() => { setShowCreate(false); loadTools(); loadStats(); }}
/>
)}
{/* Detail Modal */}
{detailTool && (
<McpToolDetailModal
tool={detailTool}
onClose={() => setDetailTool(null)}
onCopyLink={() => detailTool.deep_link && copyDeepLink(detailTool.deep_link)}
/>
)}
</div>
);
};
// ============================================================
// Sub Components
// ============================================================
const StatCard: React.FC<{ label: string; value: number; sub: string; color: string }> = ({ label, value, sub, color }) => (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #30363d)',
}}>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 4 }}>{sub}</div>
</div>
);
const ToolCard: React.FC<{
tool: McpTool;
onView: () => void;
onScan: () => void;
onDelete: () => void;
onCopyLink: () => void;
}> = ({ tool, onView, onScan, onDelete, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
const stLabel = SERVER_TYPE_LABELS[tool.server_type] || tool.server_type;
const catLabel = MCP_CATEGORY_LABELS[tool.category] || tool.category;
return (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 16,
border: '1px solid var(--color-border, #30363d)',
display: 'flex', alignItems: 'flex-start', gap: 16,
}}>
{/* Icon */}
<div style={{
width: 48, height: 48, borderRadius: 10, flexShrink: 0,
background: tool.server_type === 'builtin' ? 'linear-gradient(135deg, #58a6ff, #bc8cff)' : 'var(--color-bg-tertiary, #21262d)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#fff',
}}>
{tool.name.charAt(0).toUpperCase()}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text-primary, #e6edf3)' }}>{tool.name}</span>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{stLabel}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>托管</span>}
{tool.tools_count > 0 && <span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} tools</span>}
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 6 }}>
{tool.description || '暂无描述'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', display: 'flex', gap: 12 }}>
<span>{catLabel}</span>
{tool.package_name && <span>{tool.package_name}</span>}
{tool.publisher_name && <span>by {tool.publisher_name}</span>}
{tool.security_issues > 0 && <span style={{ color: '#f85149' }}>{tool.security_issues} 个安全问题</span>}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button onClick={onView} style={btnSmallStyle}>详情</button>
{tool.deep_link && <button onClick={onCopyLink} style={btnSmallStyle}>Deep Link</button>}
<button onClick={onScan} style={btnSmallStyle}>扫描</button>
<button onClick={onDelete} style={{ ...btnSmallStyle, color: '#f85149' }}>删除</button>
</div>
</div>
);
};
const CreateMcpToolModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const [form, setForm] = useState({
name: '', description: '', category: 'developer',
server_type: 'stdio', runtime: 'node', command: '', args: '',
package_name: '', package_registry: 'npm', package_repo_url: '',
transport_url: '', builtin_name: '',
author_name: '', license: 'MIT', tools_count: 0,
install_instructions: '',
});
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (!form.name) { alert('请输入工具名称'); return; }
if (form.server_type === 'stdio' && !form.command) { alert('STDIO 类型需要启动命令'); return; }
if (form.server_type === 'builtin' && !form.builtin_name) { alert('内置类型需要扩展名称'); return; }
if ((form.server_type === 'sse' || form.server_type === 'streamable_http') && !form.transport_url) { alert('SSE/HTTP 类型需要传输 URL'); return; }
setSaving(true);
try {
const payload: Record<string, any> = { ...form };
if (form.args) {
try { payload.args = JSON.parse(form.args); } catch { payload.args = form.args.split(' '); }
} else {
delete payload.args;
}
if (!form.tools_count) delete payload.tools_count;
if (!form.transport_url) delete payload.transport_url;
if (!form.builtin_name) delete payload.builtin_name;
const res = await adminAPI.createMcpTool(payload);
if (res?.code === 0) {
alert(`创建成功!安全状态: ${res.data?.security_status || 'pending'}`);
onSaved();
} else {
alert(res?.message || '创建失败');
}
} catch (e: any) {
alert('创建失败: ' + (e.message || '未知错误'));
}
setSaving(false);
};
return (
<div style={modalOverlayStyle}>
<div style={modalContentStyle}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20, color: 'var(--color-text-primary)' }}>创建 MCP 工具</h2>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
<FormField label="工具名称 *">
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如: Filesystem MCP" />
</FormField>
<FormField label="描述">
<textarea style={{ ...inputStyle, minHeight: 60 }} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="分类">
<select style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{Object.entries(MCP_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
<FormField label="服务器类型">
<select style={inputStyle} value={form.server_type} onChange={(e) => setForm({ ...form, server_type: e.target.value })}>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
</div>
{form.server_type === 'stdio' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="运行时">
<select style={inputStyle} value={form.runtime} onChange={(e) => setForm({ ...form, runtime: e.target.value })}>
<option value="node">Node.js</option><option value="python">Python</option>
<option value="go">Go</option><option value="rust">Rust</option>
<option value="binary">二进制</option>
</select>
</FormField>
<FormField label="启动命令 *">
<input style={inputStyle} value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="如: npx" />
</FormField>
</div>
<FormField label="参数 (JSON 数组或空格分隔)">
<input style={inputStyle} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} placeholder='如: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]' />
</FormField>
</>
)}
{form.server_type === 'builtin' && (
<FormField label="内置扩展名 *">
<input style={inputStyle} value={form.builtin_name} onChange={(e) => setForm({ ...form, builtin_name: e.target.value })} placeholder="如: developer" />
</FormField>
)}
{(form.server_type === 'sse' || form.server_type === 'streamable_http') && (
<FormField label="传输 URL *">
<input style={inputStyle} value={form.transport_url} onChange={(e) => setForm({ ...form, transport_url: e.target.value })} placeholder="https://..." />
</FormField>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="包名">
<input style={inputStyle} value={form.package_name} onChange={(e) => setForm({ ...form, package_name: e.target.value })} placeholder="@modelcontextprotocol/server-filesystem" />
</FormField>
<FormField label="包源">
<select style={inputStyle} value={form.package_registry} onChange={(e) => setForm({ ...form, package_registry: e.target.value })}>
<option value="npm">npm</option><option value="pypi">pypi</option>
<option value="github">GitHub</option><option value="custom">自定义</option>
</select>
</FormField>
</div>
<FormField label="仓库 URL">
<input style={inputStyle} value={form.package_repo_url} onChange={(e) => setForm({ ...form, package_repo_url: e.target.value })} placeholder="https://github.com/..." />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="作者">
<input style={inputStyle} value={form.author_name} onChange={(e) => setForm({ ...form, author_name: e.target.value })} />
</FormField>
<FormField label="许可证">
<input style={inputStyle} value={form.license} onChange={(e) => setForm({ ...form, license: e.target.value })} />
</FormField>
</div>
<FormField label="工具数量">
<input type="number" style={inputStyle} value={form.tools_count} onChange={(e) => setForm({ ...form, tools_count: parseInt(e.target.value) || 0 })} />
</FormField>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>取消</button>
<button onClick={handleSubmit} disabled={saving} style={btnPrimaryStyle}>{saving ? '创建中...' : '创建'}</button>
</div>
</div>
</div>
);
};
const McpToolDetailModal: React.FC<{ tool: McpToolDetail; onClose: () => void; onCopyLink: () => void }> = ({ tool, onClose, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
return (
<div style={modalOverlayStyle}>
<div style={{ ...modalContentStyle, maxWidth: 700 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text-primary)' }}>{tool.app.name}</h2>
<button onClick={onClose} style={{ ...btnSmallStyle, fontSize: 18 }}>x</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
{/* Badges */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{SERVER_TYPE_LABELS[tool.server_type] || tool.server_type}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>平台托管</span>}
<span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} 个工具</span>
<span style={{ ...badgeStyle, background: '#bc8cff22', color: '#bc8cff' }}>{tool.license}</span>
</div>
{/* Description */}
{tool.app.description && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>描述</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{tool.app.description}</div>
</div>
)}
{/* Deep Link */}
{tool.deep_link && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>Goose Deep Link</div>
<div style={{ display: 'flex', gap: 8 }}>
<input readOnly value={tool.deep_link} style={{ ...inputStyle, flex: 1, fontFamily: 'monospace', fontSize: 12 }} />
<button onClick={onCopyLink} style={btnPrimaryStyle}>复制</button>
</div>
</div>
)}
{/* Config */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
{tool.command && <DetailItem label="启动命令" value={tool.command} />}
{tool.runtime && <DetailItem label="运行时" value={tool.runtime} />}
{tool.transport_url && <DetailItem label="传输 URL" value={tool.transport_url} />}
{tool.builtin_name && <DetailItem label="内置扩展" value={tool.builtin_name} />}
{tool.package_name && <DetailItem label="包名" value={tool.package_name} />}
<DetailItem label="版本" value={tool.package_version} />
{tool.author_name && <DetailItem label="作者" value={tool.author_name} />}
{tool.managed_url && <DetailItem label="托管 URL" value={tool.managed_url} />}
</div>
{/* Args */}
{tool.args && tool.args.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>启动参数</div>
<code style={{ ...codeBlockStyle, display: 'block' }}>{JSON.stringify(tool.args, null, 2)}</code>
</div>
)}
{/* Tools List */}
{tool.tools_list && tool.tools_list.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>暴露的工具 ({tool.tools_list.length})</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{tool.tools_list.map((t, i) => (
<span key={i} style={{ ...badgeStyle, background: '#30363d', color: '#8b949e', fontFamily: 'monospace' }}>{t}</span>
))}
</div>
</div>
)}
{/* Permissions */}
{tool.permissions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>权限声明</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(tool.permissions).map(([k, v]) => (
<span key={k} style={{ ...badgeStyle, background: v ? '#f8514922' : '#30363d', color: v ? '#f85149' : '#8b949e' }}>
{k}: {v ? '是' : '否'}
</span>
))}
</div>
</div>
)}
{/* Security Report */}
{tool.security_report && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安全扫描报告</div>
<div style={{ ...codeBlockStyle, whiteSpace: 'pre-wrap' }}>{JSON.stringify(tool.security_report, null, 2)}</div>
</div>
)}
{/* Install Instructions */}
{tool.install_instructions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安装说明</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap' }}>{tool.install_instructions}</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>关闭</button>
</div>
</div>
</div>
);
};
// ============================================================
// Helper Components & Styles
// ============================================================
const FormField: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>{label}</label>
{children}
</div>
);
const DetailItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{value}</div>
</div>
);
// Style constants
const selectStyle: React.CSSProperties = {
padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
cursor: 'pointer',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
background: 'var(--color-bg-tertiary, #21262d)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
boxSizing: 'border-box',
};
const btnPrimaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-primary, #58a6ff)',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
fontWeight: 500,
};
const btnSecondaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
};
const btnSmallStyle: React.CSSProperties = {
padding: '4px 10px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 12,
whiteSpace: 'nowrap',
};
const pageBtnStyle: React.CSSProperties = {
padding: '6px 14px',
background: 'var(--color-bg-secondary, #161b22)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
};
const badgeStyle: React.CSSProperties = {
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 500,
display: 'inline-block',
};
const modalOverlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const modalContentStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 16,
padding: 24,
width: '90%',
maxWidth: 600,
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--color-border, #30363d)',
};
const labelStyle: React.CSSProperties = {
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginBottom: 4,
fontWeight: 500,
};
const codeBlockStyle: React.CSSProperties = {
background: 'var(--color-bg-tertiary, #21262d)',
borderRadius: 8,
padding: 12,
fontSize: 12,
color: 'var(--color-text-secondary)',
fontFamily: 'monospace',
overflow: 'auto',
};
export default McpMarketTab;
File diff suppressed because it is too large. Load diff
+520
View File
@@ -0,0 +1,520 @@
// @ts-nocheck
/**
* 推广系统管理页面 — 使用 admin 设计系统
*/
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
interface Promotion {
id: number; title: string; description: string; reward_type: string; reward_value: number;
min_payout: number; start_date: string; end_date: string; status: string; max_referrals: number;
created_at: string; updated_at: string; link_count: number; total_rewards: number;
}
interface ReferralLink {
id: number; code: string; user_id: number; username: string; promotion_id: number;
promotion_title: string; target_url: string; channel: string; total_clicks: number;
total_registrations: number; total_conversions: number; status: string; created_at: string;
}
interface ReferralReward {
id: number; referral_code: string; referrer_name: string; referee_name: string;
order_id: string; reward_type: string; reward_amount: number; reward_points: number;
status: string; payout_method: string; paid_at: string; created_at: string;
}
const Promotion: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'promotions' | 'links' | 'rewards' | 'leaderboard'>('overview');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [stats, setStats] = useState<any>(null);
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [referralLinks, setReferralLinks] = useState<ReferralLink[]>([]);
const [rewards, setRewards] = useState<ReferralReward[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);
const [promoPage, setPromoPage] = useState(1);
const [totalPromos, setTotalPromos] = useState(0);
const [showPromoModal, setShowPromoModal] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const [editingPromo, setEditingPromo] = useState<Promotion | null>(null);
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getPromotionStats();
const data = (res as any)?.data || res || {};
setStats(data);
} catch (err) { console.error('Stats load failed:', err); }
}, []);
const loadPromotions = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getPromotions({ page: promoPage, page_size: 20 });
const data = (res as any)?.data || res || {};
setPromotions(data.items || []);
setTotalPromos(data.total || 0);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, [promoPage]);
const loadLinks = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getReferralLinks({ page: 1, page_size: 50 });
const data = (res as any)?.data || res || {};
setReferralLinks(data.items || []);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, []);
const loadRewards = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getReferralRewards({ page: 1, page_size: 50 });
const data = (res as any)?.data || res || {};
setRewards(data.items || []);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, []);
const loadLeaderboard = useCallback(async () => {
try {
const res = await adminAPI.getPromotionLeaderboard({ limit: 50 });
const data = (res as any)?.data || res || {};
setLeaderboard(data.items || data.leaderboard || []);
} catch (err) { console.error('Leaderboard load failed:', err); }
}, []);
useEffect(() => {
setError('');
if (activeTab === 'overview') loadStats();
else if (activeTab === 'promotions') loadPromotions();
else if (activeTab === 'links') loadLinks();
else if (activeTab === 'rewards') loadRewards();
else if (activeTab === 'leaderboard') loadLeaderboard();
}, [activeTab, loadStats, loadPromotions, loadLinks, loadRewards, loadLeaderboard]);
const handleSavePromo = async (formData: any) => {
setLoading(true);
try {
if (editingPromo) await adminAPI.updatePromotion(editingPromo.id, formData);
else await adminAPI.createPromotion(formData);
setShowPromoModal(false); setEditingPromo(null); loadPromotions(); loadStats();
} catch (err) { setError('保存失败'); }
finally { setLoading(false); }
};
const handleTogglePromo = async (id: number) => {
try { await adminAPI.togglePromotion(id); loadPromotions(); }
catch (err) { setError('操作失败'); }
};
const handleDeletePromo = async (id: number) => {
if (!window.confirm('确定删除此推广活动?')) return;
try { await adminAPI.deletePromotion(id); loadPromotions(); loadStats(); }
catch (err) { setError('删除失败'); }
};
const handleGenerateLink = async (formData: any) => {
setLoading(true);
try { await adminAPI.generateReferralLink(formData); setShowLinkModal(false); loadLinks(); loadStats(); }
catch (err) { setError('生成失败'); }
finally { setLoading(false); }
};
const handleApproveReward = async (id: number) => {
try { await adminAPI.approveReward(id); loadRewards(); }
catch (err) { setError('操作失败'); }
};
const handleMarkPaid = async (id: number) => {
const payout_method = prompt('请输入提现方式(wechat/alipay/bank/balance):');
if (!payout_method) return;
try {
await adminAPI.markRewardPaid(id, { payout_method, payout_transaction_id: `TXN${Date.now()}` });
loadRewards();
} catch (err) { setError('操作失败'); }
};
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
const tabs = [
{ key: 'overview', label: '概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
{ key: 'promotions', label: '推广活动', icon: <><path d="M3 11l18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></> },
{ key: 'links', label: '分销链接', icon: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></> },
{ key: 'rewards', label: '返佣记录', icon: <><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></> },
{ key: 'leaderboard', label: '排行榜', icon: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></> },
];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
<div>
<h1 className="admin-page-title">推广系统管理</h1>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>管理推广活动、分销链接、返佣记录和推广统计</p>
</div>
{activeTab === 'promotions' && (
<button className="admin-btn admin-btn-primary" onClick={() => { setEditingPromo(null); setShowPromoModal(true); }}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
创建活动
</button>
)}
{activeTab === 'links' && (
<button className="admin-btn admin-btn-primary" onClick={() => setShowLinkModal(true)}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
生成链接
</button>
)}
</div>
{error && <div style={{ padding: '8px 16px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-md)', fontSize: 'var(--text-sm)' }}>{error}</div>}
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
{tabs.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key as any)}
style={{
padding: '10px 20px', border: 'none', cursor: 'pointer',
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
}}>
<SVGIcon size={14}>{tab.icon}</SVGIcon>
{tab.label}
</button>
))}
</div>
{/* ===================== Overview Tab ===================== */}
{activeTab === 'overview' && (
<div>
{!stats ? (
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>加载中...</div>
) : (
<>
<div className="responsive-grid-4" style={{ marginBottom: 'var(--space-xl)' }}>
{[
{ label: '推广活动总数', value: stats.total_promotions || 0, color: '#818cf8', icon: 'M3 11l18-5v12L3 14v-3z' },
{ label: '进行中的活动', value: stats.active_promotions || 0, color: '#22c55e', icon: 'M9 12l2 2 4-4' },
{ label: '分销链接总数', value: stats.total_referrals || stats.total_referral_links || 0, color: '#f59e0b', icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' },
{ label: '已发返佣总额', value: `¥${(stats.total_rewards || stats.total_rewards_paid || 0).toFixed(2)}`, color: '#06b6d4', icon: 'M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' },
].map((s, i) => (
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 'var(--space-sm)' }}>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-full)', background: `${s.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={20}><path d={s.icon}/></SVGIcon>
</div>
</div>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-header"><h3 className="admin-card-title">待审核返佣</h3></div>
<div style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 800, color: '#f59e0b' }}>¥{(stats.pending_rewards || 0).toFixed(2)}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>待审核返佣金额</div>
</div>
</div>
</>
)}
</div>
)}
{/* ===================== Promotions Tab ===================== */}
{activeTab === 'promotions' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>活动标题</th><th>返佣类型</th><th>返佣值</th>
<th>状态</th><th>链接数</th><th>已发返佣</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody>
{promotions.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : promotions.map(promo => (
<tr key={promo.id}>
<td>{promo.id}</td>
<td>
<div style={{ fontWeight: 600 }}>{promo.title}</div>
{promo.description && <div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{promo.description}</div>}
</td>
<td>
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
{promo.reward_type === 'percentage' ? '百分比' : promo.reward_type === 'fixed' ? '固定金额' : '积分'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{promo.reward_type === 'percentage' ? `${promo.reward_value}%` :
promo.reward_type === 'fixed' ? `¥${promo.reward_value?.toFixed(2)}` : `${promo.reward_value} 分`}
</td>
<td>
<span className={`admin-badge ${promo.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
{promo.status === 'active' ? '进行中' : promo.status === 'inactive' ? '已停用' : '已过期'}
</span>
</td>
<td>{promo.link_count || 0}</td>
<td>¥{(promo.total_rewards || 0).toFixed(2)}</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{promo.created_at ? new Date(promo.created_at).toLocaleDateString('zh-CN') : '-'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setEditingPromo(promo); setShowPromoModal(true); }}>编辑</button>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleTogglePromo(promo.id)}>
{promo.status === 'active' ? '停用' : '启用'}
</button>
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDeletePromo(promo.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{totalPromos > 20 && (
<div className="pagination-bar" style={{ marginTop: 'var(--space-md)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage <= 1} onClick={() => setPromoPage(p => p - 1)}>上一页</button>
<span style={{ fontSize: 'var(--text-sm)' }}>第 {promoPage} 页 / 共 {Math.ceil(totalPromos / 20)} 页</span>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage >= Math.ceil(totalPromos / 20)} onClick={() => setPromoPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* ===================== Links Tab ===================== */}
{activeTab === 'links' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>推广短码</th><th>推广人</th><th>关联活动</th>
<th>点击数</th><th>注册数</th><th>转化数</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
{referralLinks.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : referralLinks.map(link => (
<tr key={link.id}>
<td>{link.id}</td>
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{link.code}</code></td>
<td>{link.username}</td>
<td>{link.promotion_title || '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_clicks || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_registrations || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_conversions || 0}</td>
<td>
<span className={`admin-badge ${link.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
{link.status === 'active' ? '活跃' : '停用'}
</span>
</td>
<td>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => {
const url = `${link.target_url}?ref=${link.code}`;
navigator.clipboard.writeText(url);
alert('链接已复制');
}}>复制链接</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Rewards Tab ===================== */}
{activeTab === 'rewards' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>推广码</th><th>推广人</th><th>被推荐人</th>
<th>返佣类型</th><th>返佣金额</th><th>状态</th><th>打款时间</th><th>操作</th>
</tr>
</thead>
<tbody>
{rewards.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : rewards.map(reward => (
<tr key={reward.id}>
<td>{reward.id}</td>
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{reward.referral_code}</code></td>
<td>{reward.referrer_name}</td>
<td>{reward.referee_name}</td>
<td>
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
{reward.reward_type === 'registration' ? '注册奖励' : reward.reward_type === 'first_purchase' ? '首购返佣' : '续费返佣'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{reward.reward_amount?.toFixed(2)}</td>
<td>
<span className={`admin-badge ${reward.status === 'paid' ? 'badge-success' : reward.status === 'approved' ? 'badge-success' : reward.status === 'pending' ? 'badge-neutral' : 'badge-danger'}`}>
{reward.status === 'pending' ? '待审核' : reward.status === 'approved' ? '已通过' : reward.status === 'paid' ? '已打款' : '已取消'}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{reward.paid_at ? new Date(reward.paid_at).toLocaleDateString('zh-CN') : '-'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
{reward.status === 'pending' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleApproveReward(reward.id)}>审核通过</button>}
{reward.status === 'approved' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleMarkPaid(reward.id)}>标记已打款</button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Leaderboard Tab ===================== */}
{activeTab === 'leaderboard' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr><th>排名</th><th>用户</th><th>点击数</th><th>注册数</th><th>转化数</th><th>产生收入</th><th>获得返佣</th></tr>
</thead>
<tbody>
{leaderboard.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : leaderboard.map((entry, index) => (
<tr key={entry.user_id || index}>
<td>
<span style={{ fontSize: 'var(--text-lg)' }}>{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}</span>
</td>
<td style={{ fontWeight: 600 }}>{entry.username}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_clicks || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_registrations || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_conversions || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_revenue || 0).toFixed(2)}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_rewards || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Promotion Modal ===================== */}
{showPromoModal && (
<PromotionModal promotion={editingPromo} onSave={handleSavePromo} onCancel={() => { setShowPromoModal(false); setEditingPromo(null); }} />
)}
{/* ===================== Link Modal ===================== */}
{showLinkModal && (
<LinkModal onSave={handleGenerateLink} onCancel={() => setShowLinkModal(false)} />
)}
</div>
);
};
// ==================== 推广活动弹窗 ====================
const PromotionModal: React.FC<{ promotion: Promotion | null; onSave: (data: any) => void; onCancel: () => void }> = ({ promotion, onSave, onCancel }) => {
const [formData, setFormData] = useState({
title: promotion?.title || '', description: promotion?.description || '',
reward_type: promotion?.reward_type || 'percentage', reward_value: promotion?.reward_value || 5,
min_payout: promotion?.min_payout || 10, start_date: promotion?.start_date ? promotion.start_date.slice(0, 16) : '',
end_date: promotion?.end_date ? promotion.end_date.slice(0, 16) : '', max_referrals: promotion?.max_referrals || 0,
status: promotion?.status || 'active',
});
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '4vh' }} onClick={onCancel}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 600, maxHeight: '90vh', overflow: 'auto', boxShadow: 'var(--shadow-xl)' }} onClick={e => e.stopPropagation()}>
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>{promotion ? '编辑推广活动' : '创建推广活动'}</h3>
</div>
<form onSubmit={e => { e.preventDefault(); onSave(formData); }}>
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>活动标题 *</label><input style={inputStyle} required value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} placeholder="例如:新用户推广计划" /></div>
<div><label style={labelStyle}>活动描述</label><textarea style={{ ...inputStyle, resize: 'vertical' }} value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} rows={3} placeholder="活动说明..." /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>返佣类型 *</label><select style={inputStyle} value={formData.reward_type} onChange={e => setFormData({ ...formData, reward_type: e.target.value })}><option value="percentage">百分比</option><option value="fixed">固定金额</option><option value="points">积分</option></select></div>
<div><label style={labelStyle}>返佣值 *</label><input type="number" style={inputStyle} required step={formData.reward_type === 'percentage' ? 1 : 0.01} min={0} value={formData.reward_value} onChange={e => setFormData({ ...formData, reward_value: parseFloat(e.target.value) })} /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>最低提现金额</label><input type="number" style={inputStyle} step={0.01} min={0} value={formData.min_payout} onChange={e => setFormData({ ...formData, min_payout: parseFloat(e.target.value) })} /></div>
<div><label style={labelStyle}>最大推广人数 (0=不限)</label><input type="number" style={inputStyle} min={0} value={formData.max_referrals} onChange={e => setFormData({ ...formData, max_referrals: parseInt(e.target.value) })} /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>开始时间 *</label><input type="datetime-local" style={inputStyle} required value={formData.start_date} onChange={e => setFormData({ ...formData, start_date: e.target.value })} /></div>
<div><label style={labelStyle}>结束时间 (可选)</label><input type="datetime-local" style={inputStyle} value={formData.end_date} onChange={e => setFormData({ ...formData, end_date: e.target.value })} /></div>
</div>
</div>
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', borderTop: '1px solid var(--color-border)' }}>
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
<button type="submit" className="admin-btn admin-btn-primary">保存</button>
</div>
</form>
</div>
</div>
);
};
// ==================== 生成链接弹窗 ====================
const LinkModal: React.FC<{ onSave: (data: any) => void; onCancel: () => void }> = ({ onSave, onCancel }) => {
const [formData, setFormData] = useState({ promotion_id: '', target_url: 'https://bot.qihuyun.com/register', channel: 'link' });
const [promotions, setPromotions] = useState<Promotion[]>([]);
useEffect(() => {
adminAPI.getPromotions({ status: 'active', page: 1, page_size: 100 })
.then(res => { const d = (res as any)?.data || res; if (d?.items) setPromotions(d.items); })
.catch(err => console.error('Failed to load promotions:', err));
}, []);
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onCancel}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>生成分销链接</h3>
<form onSubmit={e => { e.preventDefault(); onSave({ promotion_id: parseInt(formData.promotion_id), target_url: formData.target_url, channel: formData.channel }); }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>选择推广活动 *</label><select style={inputStyle} required value={formData.promotion_id} onChange={e => setFormData({ ...formData, promotion_id: e.target.value })}><option value="">-- 请选择 --</option>{promotions.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}</select></div>
<div><label style={labelStyle}>目标URL *</label><input type="url" style={inputStyle} required value={formData.target_url} onChange={e => setFormData({ ...formData, target_url: e.target.value })} /></div>
<div><label style={labelStyle}>推广渠道</label><select style={inputStyle} value={formData.channel} onChange={e => setFormData({ ...formData, channel: e.target.value })}><option value="link">链接</option><option value="qrcode">二维码</option><option value="api">API</option></select></div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
<button type="submit" className="admin-btn admin-btn-primary">生成链接</button>
</div>
</form>
</div>
</div>
);
};
export default Promotion;
+665
View File
@@ -0,0 +1,665 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
import type { SkillState } from '@/types';
type AdminSkill = {
id: string;
user_id: string;
owner_username?: string;
skill_name: string;
description?: string;
tags: string[];
category?: string;
category_label?: string;
state: SkillState;
use_count: number;
version: number;
skill_md_path?: string;
last_used_at?: string;
created_at?: string;
updated_at?: string;
skill_md_content?: string;
publish_records?: PublishRecord[];
};
interface PublishRecord {
id: string;
price: number;
status: string;
published_at?: string;
}
interface SkillVersion {
id: string;
version: number;
description?: string;
skill_md_content?: string;
created_at?: string;
}
interface SkillsStats {
total_skills: number;
by_state: Record<string, number>;
by_category: Record<string, number>;
total_published: number;
total_versions: number;
}
const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: string }> = {
baby: { label: '萌芽期', color: '#6B7280', bg: '#F3F4F6' },
main: { label: '主力期', color: '#10B981', bg: '#D1FAE5' },
staging: { label: '灰度期', color: '#F59E0B', bg: '#FEF3C7' },
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
};
const CATEGORIES = [
{ id: 'writing', label: '文字创作' },
{ id: 'coding', label: '代码能力' },
{ id: 'data_analysis', label: '数据分析' },
{ id: 'qa', label: '知识问答' },
{ id: 'automation', label: '流程自动化' },
{ id: 'creativity', label: '创意生成' },
{ id: 'communication', label: '沟通协作' },
{ id: 'reasoning', label: '逻辑推理' },
{ id: 'other', label: '其他' },
];
const PAGE_SIZE = 15;
const Skills: React.FC = () => {
const [skills, setSkills] = useState<AdminSkill[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
// Filters
const [keyword, setKeyword] = useState('');
const [filterState, setFilterState] = useState<string>('');
const [filterCategory, setFilterCategory] = useState<string>('');
// Create/Edit Modal
const [showModal, setShowModal] = useState(false);
const [editingSkill, setEditingSkill] = useState<AdminSkill | null>(null);
const [form, setForm] = useState({
skill_name: '',
user_id: '',
description: '',
tags: '',
category: '',
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
});
// Version Drawer
const [showVersions, setShowVersions] = useState(false);
const [versions, setVersions] = useState<any[]>([]);
const [versionSkill, setVersionSkill] = useState<AdminSkill | null>(null);
const [loadingVersions, setLoadingVersions] = useState(false);
// Users list (for owner selection)
const [users, setUsers] = useState<{id: string; username: string}[]>([]);
// Confirm delete
const [deleteTarget, setDeleteTarget] = useState<AdminSkill | null>(null);
// Toast
const [toast, setToast] = useState<{msg: string; type: 'success' | 'error'} | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load skills list
const loadSkills = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (keyword) params.keyword = keyword;
if (filterState) params.state = filterState;
if (filterCategory) params.category = filterCategory;
const result = await adminAPI.getAdminSkills(params) as any;
const data = result.data || result;
setSkills(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(`加载失败: ${e.message || e}`, 'error');
} finally {
setLoading(false);
}
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminSkillsStats() as any;
const data = result.data || result;
setStats(data);
} catch {}
};
// Load users for owner selection
const loadUsers = async () => {
try {
const result = await adminAPI.getUsers({ page_size: 100 }) as any;
const data = result.data || result;
setUsers((data.items || data || []).slice(0, 20));
} catch {}
};
useEffect(() => {
loadSkills();
}, [page, keyword, filterState, filterCategory]);
useEffect(() => {
loadStats();
loadUsers();
}, []);
// Search
const handleSearch = () => {
setPage(1);
loadSkills();
};
// Open create modal
const openCreate = () => {
setEditingSkill(null);
setForm({
skill_name: '',
user_id: users[0]?.id || '',
description: '',
tags: '',
category: '',
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
});
setShowModal(true);
};
// Open edit modal
const openEdit = (skill: AdminSkill) => {
setEditingSkill(skill);
setForm({
skill_name: skill.skill_name,
user_id: skill.user_id,
description: skill.description || '',
tags: (skill.tags || []).join(', '),
category: skill.category || '',
skill_md_content: skill.skill_md_content || '',
});
setShowModal(true);
};
// Save skill
const handleSave = async () => {
if (!form.skill_name.trim()) {
showToast('技能名称不能为空', 'error');
return;
}
if (!form.user_id) {
showToast('请选择所属用户', 'error');
return;
}
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean);
const payload: any = {
skill_name: form.skill_name,
user_id: form.user_id,
description: form.description,
tags,
category: form.category || undefined,
skill_md_content: form.skill_md_content,
};
try {
if (editingSkill) {
await adminAPI.updateAdminSkill(editingSkill.id, payload);
showToast('技能已更新');
} else {
await adminAPI.createAdminSkill(payload);
showToast('技能已创建');
}
setShowModal(false);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`保存失败: ${e.message || e}`, 'error');
}
};
// Delete skill
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await adminAPI.deleteAdminSkill(deleteTarget.id);
showToast('技能已删除');
setDeleteTarget(null);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`删除失败: ${e.message || e}`, 'error');
}
};
// Change state
const handleChangeState = async (skill: AdminSkill, newState: string) => {
try {
await adminAPI.changeAdminSkillState(skill.id, { state: newState });
showToast(`状态已变更为 ${STATE_CONFIG[newState as SkillState].label}`);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`状态修改失败: ${e.message || e}`, 'error');
}
};
// Load version history
const handleShowVersions = async (skill: AdminSkill) => {
setVersionSkill(skill);
setShowVersions(true);
setLoadingVersions(true);
try {
const result = await adminAPI.getAdminSkillVersions(skill.id) as any;
const data = result.data || result;
setVersions(data.versions || []);
} catch (e: any) {
showToast(`加载版本历史失败: ${e.message || e}`, 'error');
setVersions([]);
} finally {
setLoadingVersions(false);
}
};
// Rollback version
const handleRollback = async (skill: AdminSkill, targetVersion: number) => {
if (!confirm(`确定回滚到版本 ${targetVersion} 吗?`)) return;
try {
await adminAPI.rollbackAdminSkill(skill.id, { version: targetVersion });
showToast(`已回滚到版本 ${targetVersion}`);
setShowVersions(false);
loadSkills();
} catch (e: any) {
showToast(`回滚失败: ${e.message || e}`, 'error');
}
};
// Publish/Unpublish
const handlePublish = async (skill: AdminSkill) => {
const price = parseFloat(prompt('设置定价(元,0=免费):', '0') || '0');
if (isNaN(price) || price < 0) return;
try {
await adminAPI.publishAdminSkill(skill.id, { price, market_description: skill.description || '' });
showToast('已发布到应用市场');
loadSkills();
} catch (e: any) {
showToast(`发布失败: ${e.message || e}`, 'error');
}
};
const handleUnpublish = async (skill: AdminSkill) => {
if (!confirm('确定从市场下架吗?')) return;
try {
await adminAPI.unpublishAdminSkill(skill.id);
showToast('已从市场下架');
loadSkills();
} catch (e: any) {
showToast(`下架失败: ${e.message || e}`, 'error');
}
};
const totalPages = Math.ceil(total / PAGE_SIZE);
const isPublished = (skill: AdminSkill) =>
(skill.publish_records || []).some(r => r.status === 'published');
return (
<div className="admin-page">
{/* Toast */}
{toast && (
<div className={`toast ${toast.type}`} style={{
position: 'fixed', top: 20, right: 20, zIndex: 9999,
padding: '12px 20px', borderRadius: 8,
background: toast.type === 'success' ? '#10B981' : '#EF4444',
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="admin-page-header">
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>技能管理</h2>
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
管理全平台 AI 技能,支持版本历史与市场发布
</p>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
<div className="stat-card">
<div className="stat-value">{stats.total_skills}</div>
<div className="stat-label">技能总数</div>
</div>
{Object.entries(STATE_CONFIG).map(([state, cfg]) => (
<div key={state} className="stat-card" style={{ borderLeft: `3px solid ${cfg.color}` }}>
<div className="stat-value">{stats.by_state[state] || 0}</div>
<div className="stat-label">{cfg.label}</div>
</div>
))}
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
<div className="stat-value">{stats.total_published}</div>
<div className="stat-label">已发布市场</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
<div className="stat-value">{stats.total_versions}</div>
<div className="stat-label">版本记录</div>
</div>
</div>
)}
{/* Filters */}
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
<input
className="admin-form-input"
placeholder="搜索技能名称或描述..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
style={{ maxWidth: 280 }}
/>
<select className="admin-form-input" value={filterState} onChange={e => { setFilterState(e.target.value); setPage(1); }} style={{ maxWidth: 140 }}>
<option value="">全部状态</option>
{Object.entries(STATE_CONFIG).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<select className="admin-form-input" value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} style={{ maxWidth: 160 }}>
<option value="">全部分类</option>
{CATEGORIES.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
<button className="admin-btn" onClick={handleSearch}>搜索</button>
{(keyword || filterState || filterCategory) && (
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterState(''); setFilterCategory(''); setPage(1); }}>
清空筛选
</button>
)}
</div>
{/* Table */}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>技能名称</th>
<th>状态</th>
<th>分类</th>
<th>所有者</th>
<th>版本</th>
<th>使用次数</th>
<th>市场</th>
<th>更新时间</th>
<th style={{ width: 280 }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
) : skills.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
) : skills.map(skill => {
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
const published = isPublished(skill);
return (
<tr key={skill.id}>
<td>
<div style={{ fontWeight: 500 }}>{skill.skill_name}</div>
{skill.description && (
<div style={{ fontSize: 12, color: '#6B7280', marginTop: 2, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{skill.description}
</div>
)}
{skill.tags && skill.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
))}
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
</div>
)}
</td>
<td>
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12,
background: cfg.bg, color: cfg.color, fontWeight: 500
}}>
{cfg.label}
</span>
</td>
<td>{skill.category_label || '-'}</td>
<td>{skill.owner_username || skill.user_id}</td>
<td>v{skill.version}</td>
<td>{skill.use_count}</td>
<td>
{published ? (
<span style={{ color: '#10B981', fontSize: 13 }}>✓ 已发布</span>
) : (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>未发布</span>
)}
</td>
<td style={{ fontSize: 12, color: '#6B7280' }}>
{skill.updated_at ? skill.updated_at.slice(0, 16).replace('T', ' ') : '-'}
</td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="admin-btn small" onClick={() => openEdit(skill)}>编辑</button>
<button className="admin-btn small" onClick={() => handleShowVersions(skill)}>版本</button>
{published ? (
<button className="admin-btn small warning" onClick={() => handleUnpublish(skill)}>下架</button>
) : (
<button className="admin-btn small success" onClick={() => handlePublish(skill)}>发布</button>
)}
{/* State change */}
<select
className="admin-form-input small"
value={skill.state}
onChange={e => handleChangeState(skill, e.target.value)}
style={{ padding: '2px 4px', fontSize: 12, maxWidth: 90 }}
>
{Object.entries(STATE_CONFIG).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<button className="admin-btn small danger" onClick={() => setDeleteTarget(skill)}>删除</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="admin-pagination">
<span style={{ color: '#6B7280', fontSize: 13 }}>
共 {total} 条,第 {page}/{totalPages} 页
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
return p <= totalPages && (
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`}
onClick={() => setPage(p)}>{p}</button>
);
})}
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editingSkill ? '编辑技能' : '创建技能'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">技能名称 *</label>
<input className="admin-form-input" value={form.skill_name}
onChange={e => setForm(f => ({ ...f, skill_name: e.target.value }))}
placeholder="例如: 代码审查助手" />
</div>
{!editingSkill && (
<div className="form-group">
<label className="form-label">所属用户 *</label>
<select className="admin-form-input" value={form.user_id}
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
)}
<div className="form-group">
<label className="form-label">分类</label>
<select className="admin-form-input" value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
<option value="">选择分类</option>
{CATEGORIES.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">标签(逗号分隔)</label>
<input className="admin-form-input" value={form.tags}
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="例如: 代码, 审查, 自动化" />
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">描述</label>
<textarea className="admin-form-input" value={form.description} rows={3}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="描述技能的功能和使用场景..." />
</div>
</div>
<div className="form-group">
<label className="form-label">SKILL.md 内容(技能定义)</label>
<textarea
className="admin-form-input"
value={form.skill_md_content}
onChange={e => setForm(f => ({ ...f, skill_md_content: e.target.value }))}
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 400, lineHeight: 1.6 }}
placeholder="## 技能定义..."
/>
</div>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
<button className="admin-btn primary" onClick={handleSave}>
{editingSkill ? '保存更改' : '创建技能'}
</button>
</div>
</div>
</div>
)}
{/* Version History Drawer */}
{showVersions && versionSkill && (
<div className="modal-overlay" onClick={() => setShowVersions(false)}>
<div className="modal-content" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>版本历史 - {versionSkill.skill_name}</h3>
<button className="modal-close" onClick={() => setShowVersions(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{loadingVersions ? (
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</div>
) : versions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无版本记录</div>
) : (
<div>
{versions.map((v, idx) => (
<div key={v.id} style={{
padding: 16, marginBottom: 12, border: `1px solid ${idx === 0 ? '#10B981' : '#E5E7EB'}`,
borderRadius: 8, background: idx === 0 ? '#F0FDF4' : '#fff'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div>
<span style={{ fontWeight: 600, fontSize: 15 }}>版本 {v.version}</span>
{idx === 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#10B981', background: '#D1FAE5', padding: '1px 8px', borderRadius: 12 }}>当前</span>}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{v.created_at?.slice(0, 16).replace('T', ' ')}</span>
{idx > 0 && (
<button className="admin-btn small" onClick={() => handleRollback(versionSkill, v.version)}>回滚至此</button>
)}
</div>
</div>
{v.description && <div style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>{v.description}</div>}
{v.skill_md_content && (
<details>
<summary style={{ fontSize: 13, color: '#6366F1', cursor: 'pointer' }}>查看 SKILL.md 内容</summary>
<pre style={{ fontSize: 12, background: '#F9FAFB', padding: 12, borderRadius: 6, marginTop: 8, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{v.skill_md_content}
</pre>
</details>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* Delete Confirm */}
{deleteTarget && (
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
<div className="modal-content" style={{ maxWidth: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>确认删除</h3>
</div>
<div className="modal-body">
<p>确定要删除技能 <strong>{deleteTarget.skill_name}</strong> 吗?</p>
<p style={{ fontSize: 13, color: '#EF4444' }}>此操作不可恢复,相关版本历史也将被删除。</p>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setDeleteTarget(null)}>取消</button>
<button className="admin-btn danger" onClick={handleDelete}>确认删除</button>
</div>
</div>
</div>
)}
<style>{`
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { margin: 0; font-size: 18px; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
.modal-body { padding: 24px; overflow-y: auto; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
`}</style>
</div>
);
};
export default Skills;
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Tenant {
id: string; name: string; slug: string; logo: string; description: string;
status: string; max_members: number; max_storage_mb: number;
max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string;
}
interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; }
interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; }
const Tenant: React.FC = () => {
const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list');
const [tenants, setTenants] = useState<Tenant[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [selectedTenant, setSelectedTenant] = useState<string>('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
const fetchTenants = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getTenants(page);
const d = (res as any)?.data || res;
setTenants(d?.items || []);
setTotal(d?.total || 0);
} catch (e) { console.error('fetchTenants:', e); }
setLoading(false);
}, [page]);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getTenantStats();
setStats((res as any)?.data || res);
} catch (e) { console.error('fetchStats:', e); }
}, []);
const fetchMembers = useCallback(async () => {
if (!selectedTenant) return;
try {
const res = await adminAPI.getTenantMembers(selectedTenant);
const d = (res as any)?.data || res;
setMembers(d?.items || []);
} catch (e) { console.error('fetchMembers:', e); }
}, [selectedTenant]);
useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]);
useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]);
const handleCreate = async () => {
try {
const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`;
await adminAPI.createTenant(params);
setShowCreate(false);
setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
fetchTenants();
} catch (e) { console.error('create:', e); }
};
const statusColors: Record<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#6B7280', viewer: '#9CA3AF' };
return (
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
{/* Stats cards */}
{stats && (
<div className="responsive-grid-auto" style={{ gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总租户数', value: stats.total_tenants, color: '#6C63FF' },
{ label: '活跃租户', value: stats.active_tenants, color: '#10b981' },
{ label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' },
{ label: '总成员数', value: stats.total_members, color: '#3b82f6' },
{ label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' },
].map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280',
fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
{tab === 'list' && (
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
border: 'none', cursor: 'pointer', background: '#6C63FF', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
+ 创建租户
</button>
)}
</div>
{/* Tab content */}
{tab === 'list' && (
<div>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead>
<tr style={{ background: '#F8F9FC' }}>
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{tenants.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.slug}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 10)}</td>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)',
background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '12px' }}>管理</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{total > 20 && (
<div className="pagination-bar" style={{ marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</div>
)}
{tab === 'members' && (
<div>
<div style={{ marginBottom: '16px' }}>
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '8px', background: '#FFFFFF', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px' }}>
<option value="">选择租户...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{selectedTenant && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead>
<tr style={{ background: '#F8F9FC' }}>
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{members.map(m => (
<tr key={m.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{m.email || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{m.joined_at?.substring(0, 16)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{tab === 'stats' && stats && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '32px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div className="responsive-grid-2" style={{ gap: '24px' }}>
{Object.entries(stats).map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#F8F9FC', borderRadius: '8px' }}>
<span style={{ fontSize: '14px', color: '#6B7280' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6C63FF' }}>{String(v)}</span>
</div>
))}
</div>
</div>
)}
{/* Create modal */}
{showCreate && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setShowCreate(false)}>
<div onClick={e => e.stopPropagation()} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw', boxShadow: '0 4px 24px rgba(0,0,0,0.12)' }}>
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px', color: '#1A1F36' }}>创建租户</h2>
{[
{ k: 'name', label: '租户名称', type: 'text' },
{ k: 'slug', label: '标识(英文)', type: 'text' },
{ k: 'description', label: '描述', type: 'text' },
{ k: 'max_members', label: '最大成员数', type: 'number' },
{ k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' },
{ k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' },
].map(f => (
<div key={f.k} style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', color: '#6B7280', marginBottom: '6px' }}>{f.label}</label>
<input type={f.type} value={(form as any)[f.k]} onChange={e => setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })}
style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#F8F9FC', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
))}
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid rgba(0,0,0,0.12)', background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6C63FF', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Tenant;
+757
View File
@@ -0,0 +1,757 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
interface WorkflowNode {
id: string;
type: string;
position: { x: number; y: number };
data: { label: string; config: Record<string, any> };
}
interface WorkflowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
}
interface Workflow {
id: string;
name: string;
description: string;
agent_id: string | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
variables: Record<string, any>;
status: string;
version: number;
created_at: string;
updated_at: string;
}
interface NodeTemplate {
type: string;
label: string;
icon: string;
color: string;
description: string;
config_schema: Record<string, any>;
}
const NODE_COLORS: Record<string, string> = {
start: "#3B6D11",
llm: "#534AB7",
knowledge: "#185FA5",
skill: "#0F6E56",
condition: "#BA7517",
http: "#D4537E",
code: "#5F5E5A",
agent: "#7C3AED",
end: "#A32D2D",
};
const NODE_LABELS: Record<string, string> = {
start: "开始",
llm: "LLM 调用",
knowledge: "知识库检索",
skill: "Skill 调用",
condition: "条件分支",
http: "HTTP 请求",
code: "代码执行",
agent: "Agent 节点",
end: "结束",
};
export default function Workflow() {
const navigate = useNavigate();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"list" | "editor">("list");
const [currentWf, setCurrentWf] = useState<Workflow | null>(null);
const [templates, setTemplates] = useState<NodeTemplate[]>([]);
const [options, setOptions] = useState<Record<string, any[]>>({});
const [selectedNode, setSelectedNode] = useState<WorkflowNode | null>(null);
const [draggingNode, setDraggingNode] = useState<string | null>(null);
const [connectingFrom, setConnectingFrom] = useState<string | null>(null);
const [executeResult, setExecuteResult] = useState<any>(null);
const [executeInput, setExecuteInput] = useState("");
const [showExecute, setShowExecute] = useState(false);
const [executing, setExecuting] = useState(false);
const [nodeStatus, setNodeStatus] = useState<Record<string, string>>({});
const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const api = (window as any).api;
// Load workflows
const loadWorkflows = useCallback(async () => {
setLoading(true);
try {
const res = await api.get("/admin/workflows/");
if (res.code === 0) setWorkflows(res.data.items || []);
} catch (e) { console.error(e); }
try {
const s = await api.get("/admin/workflows/stats");
if (s.code === 0) setStats(s.data);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
// Load templates
const loadTemplates = useCallback(async () => {
try {
const res = await api.get("/admin/workflows/templates");
if (res.code === 0) {
setTemplates(Object.values(res.data.node_templates || {}));
setOptions(res.data.options || {});
}
} catch (e) { console.error(e); }
}, []);
useEffect(() => { loadWorkflows(); loadTemplates(); }, []);
// Create new workflow
const handleCreate = async () => {
const name = prompt("工作流名称:", "新工作流");
if (!name) return;
try {
const res = await api.post("/admin/workflows/", {
name,
description: "",
nodes: [
{ id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } },
{ id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } },
],
edges: [],
});
if (res.code === 0) {
loadWorkflows();
handleOpenEditor(res.data);
}
} catch (e) { console.error(e); }
};
// Open editor
const handleOpenEditor = (wf: Workflow) => {
setCurrentWf(wf);
setSelectedNode(null);
setView("editor");
};
// Save workflow
const handleSave = async () => {
if (!currentWf) return;
try {
const res = await api.put(`/admin/workflows/${currentWf.id}`, {
name: currentWf.name,
description: currentWf.description,
nodes: currentWf.nodes,
edges: currentWf.edges,
variables: currentWf.variables,
});
if (res.code === 0) {
setCurrentWf(res.data);
alert("保存成功");
}
} catch (e) { console.error(e); }
};
// Delete workflow
const handleDelete = async (id: string) => {
if (!confirm("确认删除此工作流?")) return;
try {
await api.del(`/admin/workflows/${id}`);
loadWorkflows();
} catch (e) { console.error(e); }
};
// Toggle publish
const handleToggle = async (wf: Workflow) => {
const isActive = wf.status !== "published";
try {
await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`);
loadWorkflows();
} catch (e) { console.error(e); }
};
// Add node to canvas
const handleAddNode = (template: NodeTemplate) => {
if (!currentWf) return;
const id = `${template.type}-${Date.now()}`;
const newNode: WorkflowNode = {
id,
type: template.type,
position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 },
data: {
label: template.label,
config: Object.fromEntries(
Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""])
),
},
};
setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] });
};
// Node drag
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
const node = currentWf!.nodes.find(n => n.id === nodeId);
if (!node || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
dragOffset.current = {
x: e.clientX - rect.left - node.position.x,
y: e.clientY - rect.top - node.position.y,
};
setDraggingNode(nodeId);
setSelectedNode(node);
};
const handleCanvasMouseMove = (e: React.MouseEvent) => {
if (!draggingNode || !currentWf || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left - dragOffset.current.x;
const y = e.clientY - rect.top - dragOffset.current.y;
setCurrentWf({
...currentWf,
nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n),
});
};
const handleCanvasMouseUp = () => { setDraggingNode(null); };
// Canvas click (deselect / cancel connecting)
const handleCanvasClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
setSelectedNode(null);
setConnectingFrom(null);
}
};
// Start connecting from a node
const handleStartConnect = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
setConnectingFrom(nodeId);
};
// Complete connection to a node
const handleEndConnect = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
if (!connectingFrom || !currentWf) return;
if (connectingFrom === nodeId) { setConnectingFrom(null); return; }
// Check if edge already exists
const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId);
if (exists) { setConnectingFrom(null); return; }
const newEdge: WorkflowEdge = {
id: `e-${Date.now()}`,
source: connectingFrom,
target: nodeId,
};
setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] });
setConnectingFrom(null);
};
// Delete edge
const handleDeleteEdge = (edgeId: string) => {
if (!currentWf) return;
setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) });
};
// Delete selected node
const handleDeleteNode = () => {
if (!selectedNode || !currentWf) return;
const nodeId = selectedNode.id;
setCurrentWf({
...currentWf,
nodes: currentWf.nodes.filter(n => n.id !== nodeId),
edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
});
setSelectedNode(null);
};
// Update node config
const handleConfigChange = (key: string, value: any) => {
if (!selectedNode || !currentWf) return;
const updatedNodes = currentWf.nodes.map(n =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } }
: n
);
setCurrentWf({ ...currentWf, nodes: updatedNodes });
setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null);
};
// Execute workflow (supports streaming via WebSocket when available)
const handleExecute = async () => {
if (!currentWf) return;
setExecuting(true);
setExecuteResult(null);
setNodeStatus({});
// Highlight running nodes in real-time
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = window.location.host;
const wsUrl = `${wsProtocol}//${wsHost}/app/api/admin/workflows/${currentWf.id}/execute-stream`;
let ws: WebSocket | null = null;
let result: any = null;
try {
// Connect WebSocket
ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "node_start") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "running" }));
// Add to log
setExecuteResult((prev: any) => ({
...prev,
log: [...(prev?.log || []), {
node_id: msg.node_id,
node_label: msg.node_label,
node_type: msg.node_type,
success: null,
}],
}));
} else if (msg.type === "node_complete") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "completed" }));
// Update log entry
setExecuteResult((prev: any) => {
const log = (prev?.log || []).map((l: any) =>
l.node_id === msg.node_id ? { ...l, success: true, output_preview: msg.output_preview } : l
);
return { ...prev, log };
});
} else if (msg.type === "node_error") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "failed" }));
setExecuteResult((prev: any) => {
const log = (prev?.log || []).map((l: any) =>
l.node_id === msg.node_id ? { ...l, success: false, error: msg.error } : l
);
return { ...prev, log };
});
} else if (msg.type === "workflow_complete") {
result = msg.data;
setExecuteResult(msg.data);
} else if (msg.type === "workflow_error") {
result = msg.data;
setExecuteResult(msg.data);
}
} catch (e) { /* ignore parse errors */ }
};
ws.onopen = () => {
ws?.send(JSON.stringify({
input_text: executeInput,
token: (window as any).api?.getToken?.() || "",
}));
};
// Also call REST API as fallback
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
input_text: executeInput,
});
if (res.code === 0 && !result) {
setExecuteResult(res.data);
}
} catch (e) {
console.error(e);
// Try REST API fallback
try {
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
input_text: executeInput,
});
if (res.code === 0) setExecuteResult(res.data);
} catch (e2) { console.error(e2); }
} finally {
setExecuting(false);
setTimeout(() => {
if (ws?.readyState === WebSocket.OPEN) ws.close();
}, 1000);
}
};
// SVG edge path (bezier curve)
const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => {
const x1 = src.position.x + 90;
const y1 = src.position.y + 25;
const x2 = tgt.position.x;
const y2 = tgt.position.y + 25;
const dx = Math.abs(x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
};
// ============================================================
// LIST VIEW
// ============================================================
if (view === "list") {
return (
<div style={{ padding: "24px", color: "#e0e0e0", minHeight: "100vh" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
<div>
<h2 style={{ margin: 0, fontSize: "20px", color: "#fff" }}>Agent 可视化编排</h2>
<p style={{ margin: "4px 0 0", color: "#888", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · LLM/知识库/Skill/条件/HTTP/代码/Agent 节点 · 并行多 Agent 协作</p>
</div>
<button onClick={handleCreate} style={btnPrimary}>+ 新建工作流</button>
</div>
{/* Stats */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "16px", marginBottom: "24px" }}>
{[
{ label: "总工作流", value: stats.total, color: "#534AB7" },
{ label: "草稿", value: stats.draft, color: "#BA7517" },
{ label: "已发布", value: stats.published, color: "#3B6D11" },
{ label: "已归档", value: stats.archived, color: "#5F5E5A" },
].map(s => (
<div key={s.label} style={{ ...statCard, borderLeft: `3px solid ${s.color}` }}>
<div style={{ fontSize: "24px", fontWeight: 600, color: "#fff" }}>{s.value}</div>
<div style={{ fontSize: "12px", color: "#888" }}>{s.label}</div>
</div>
))}
</div>
{/* Workflow list */}
<div style={{ background: "#1a1a2e", borderRadius: "12px", overflow: "hidden" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid #333" }}>
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#888", fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>加载中...</td></tr>
) : workflows.length === 0 ? (
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>暂无工作流,点击"新建工作流"开始</td></tr>
) : workflows.map(wf => (
<tr key={wf.id} style={{ borderBottom: "1px solid #222" }}>
<td style={{ padding: "12px 16px" }}>
<div style={{ color: "#fff", fontWeight: 500 }}>{wf.name}</div>
<div style={{ color: "#666", fontSize: "12px" }}>{wf.description || "—"}</div>
</td>
<td style={{ padding: "12px 16px" }}>
<span style={statusBadge(wf.status)}>{wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"}</span>
</td>
<td style={{ padding: "12px 16px", color: "#aaa" }}>{wf.nodes?.length || 0}</td>
<td style={{ padding: "12px 16px", color: "#aaa" }}>v{wf.version}</td>
<td style={{ padding: "12px 16px", color: "#888", fontSize: "12px" }}>{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"}</td>
<td style={{ padding: "12px 16px" }}>
<button onClick={() => handleOpenEditor(wf)} style={btnSmall}>编辑</button>
<button onClick={() => handleToggle(wf)} style={{ ...btnSmall, marginLeft: "4px" }}>{wf.status === "published" ? "归档" : "发布"}</button>
<button onClick={() => handleDelete(wf.id)} style={{ ...btnSmall, marginLeft: "4px", color: "#F09595" }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ============================================================
// EDITOR VIEW
// ============================================================
if (!currentWf) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#0d0d1a", color: "#e0e0e0" }}>
{/* Toolbar */}
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#16162a", borderBottom: "1px solid #2a2a4a", gap: "8px" }}>
<button onClick={() => { setView("list"); loadWorkflows(); }} style={btnSmall}>← 返回</button>
<input
value={currentWf.name}
onChange={e => setCurrentWf({ ...currentWf, name: e.target.value })}
style={{ background: "transparent", border: "1px solid #333", borderRadius: "6px", padding: "4px 12px", color: "#fff", fontSize: "14px", width: "200px" }}
/>
<span style={{ color: "#666", fontSize: "12px" }}>v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线</span>
<div style={{ flex: 1 }} />
<button onClick={handleSave} style={btnPrimary}>保存</button>
<button onClick={() => setShowExecute(!showExecute)} style={{ ...btnSmall, borderColor: "#3B6D11" }}>执行</button>
{selectedNode && <button onClick={handleDeleteNode} style={{ ...btnSmall, color: "#F09595" }}>删除节点</button>}
</div>
{/* Execute panel */}
{showExecute && (
<div style={{ padding: "12px 16px", background: "#1a1a2e", borderBottom: "1px solid #2a2a4a" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#888" }}>输入文本</label>
<textarea
value={executeInput}
onChange={e => setExecuteInput(e.target.value)}
placeholder="输入测试文本..."
style={{ width: "100%", background: "#0d0d1a", border: "1px solid #333", borderRadius: "6px", padding: "8px", color: "#fff", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
/>
</div>
<button onClick={handleExecute} style={{ ...btnPrimary, opacity: executing ? 0.6 : 1 }} disabled={executing}>
{executing ? "执行中..." : "执行工作流"}
</button>
</div>
{executing && (
<div style={{ marginTop: "8px", fontSize: "12px", color: "#534AB7" }}>
⏳ 工作流执行中...
</div>
)}
{executeResult && (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>执行日志:</div>
{executeResult.log?.map((l: any, i: number) => (
<div key={i} style={{ fontSize: "12px", padding: "4px 8px", background: l.success ? "rgba(59,109,17,0.15)" : "rgba(163,45,45,0.15)", borderRadius: "4px", marginBottom: "2px" }}>
<span style={{ color: l.success ? "#97C459" : "#F09595" }}>[{l.node_type}]</span>{" "}
<span style={{ color: "#aaa" }}>{l.node_label}</span>
{l.output_preview && <span style={{ color: "#666" }}> → {l.output_preview.slice(0, 100)}</span>}
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
</div>
))}
<div style={{ marginTop: "8px", padding: "8px", background: "#0d0d1a", borderRadius: "6px", border: "1px solid #333" }}>
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>最终输出:</div>
<pre style={{ margin: 0, color: "#97C459", fontSize: "13px", whiteSpace: "pre-wrap" }}>{executeResult.output}</pre>
</div>
</div>
)}
</div>
)}
{/* Main area: palette | canvas | config */}
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Node palette */}
<div style={{ width: "180px", background: "#16162a", borderRight: "1px solid #2a2a4a", padding: "12px", overflowY: "auto" }}>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px", textTransform: "uppercase" }}>节点类型</div>
{Object.entries(NODE_LABELS).map(([type, label]) => (
<div
key={type}
onClick={() => {
const tmpl = templates.find(t => t.type === type);
if (tmpl) handleAddNode(tmpl);
else handleAddNode({ type, label, icon: "", color: NODE_COLORS[type], description: "", config_schema: {} });
}}
style={{
padding: "8px 12px",
marginBottom: "4px",
borderRadius: "6px",
cursor: "pointer",
background: "#1a1a2e",
border: "1px solid #333",
borderLeft: `3px solid ${NODE_COLORS[type]}`,
transition: "background 0.2s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "#222244")}
onMouseLeave={e => (e.currentTarget.style.background = "#1a1a2e")}
>
<span style={{ color: "#fff", fontSize: "13px" }}>{label}</span>
</div>
))}
</div>
{/* Canvas */}
<div
ref={canvasRef}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
style={{
flex: 1,
position: "relative",
overflow: "hidden",
backgroundImage: "radial-gradient(circle, #1a1a2e 1px, transparent 1px)",
backgroundSize: "20px 20px",
background: "#0d0d1a",
}}
>
{/* SVG for edges */}
<svg style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
{currentWf.edges.map(edge => {
const src = currentWf.nodes.find(n => n.id === edge.source);
const tgt = currentWf.nodes.find(n => n.id === edge.target);
if (!src || !tgt) return null;
return (
<g key={edge.id} style={{ pointerEvents: "auto" }}>
<path d={getEdgePath(src, tgt)} fill="none" stroke="#534AB7" strokeWidth={2} markerEnd="url(#arrowhead)" />
<circle cx={(src.position.x + tgt.position.x + 90) / 2} cy={(src.position.y + tgt.position.y + 25) / 2} r={8} fill="#1a1a2e" stroke="#534AB7" strokeWidth={1} style={{ cursor: "pointer" }} onClick={() => handleDeleteEdge(edge.id)} />
<text x={(src.position.x + tgt.position.x + 90) / 2} y={(src.position.y + tgt.position.y + 25) / 2 + 3} textAnchor="middle" fill="#F09595" fontSize="10" style={{ pointerEvents: "none" }}>x</text>
</g>
);
})}
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#534AB7" />
</marker>
</defs>
</svg>
{/* Nodes */}
{currentWf.nodes.map(node => {
const isSelected = selectedNode?.id === node.id;
const status = nodeStatus[node.id];
const baseColor = NODE_COLORS[node.type] || "#5F5E5A";
const color = status === "running" ? "#58a6ff" : status === "completed" ? "#3fb950" : status === "failed" ? "#f85149" : baseColor;
const borderWidth = isSelected || status ? 2 : 1;
return (
<div
key={node.id}
onMouseDown={e => handleNodeMouseDown(e, node.id)}
onClick={e => { e.stopPropagation(); setSelectedNode(node); }}
style={{
position: "absolute",
left: node.position.x,
top: node.position.y,
width: "90px",
minHeight: "50px",
background: "#1a1a2e",
border: `${borderWidth}px solid ${color}`,
borderRadius: "8px",
cursor: draggingNode === node.id ? "grabbing" : "grab",
padding: "8px",
textAlign: "center",
boxShadow: isSelected ? `0 0 12px ${color}40` : "none",
zIndex: isSelected ? 10 : 1,
}}
>
<div style={{ fontSize: "11px", color, fontWeight: 600, marginBottom: "2px" }}>{NODE_LABELS[node.type] || node.type}</div>
<div style={{ fontSize: "11px", color: "#aaa" }}>{node.data.label}</div>
{/* Connection handles */}
<div
onClick={e => connectingFrom ? handleEndConnect(e, node.id) : handleStartConnect(e, node.id)}
style={{
position: "absolute", right: -6, top: "50%", transform: "translateY(-50%)",
width: 12, height: 12, borderRadius: "50%",
background: connectingFrom === node.id ? "#97C459" : "#534AB7",
cursor: "pointer", border: "2px solid #1a1a2e",
}}
/>
<div
onClick={e => connectingFrom && handleEndConnect(e, node.id)}
style={{
position: "absolute", left: -6, top: "50%", transform: "translateY(-50%)",
width: 12, height: 12, borderRadius: "50%",
background: "#444", cursor: "pointer", border: "2px solid #1a1a2e",
}}
/>
</div>
);
})}
{connectingFrom && (
<div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", background: "#534AB7", color: "#fff", padding: "4px 12px", borderRadius: "6px", fontSize: "12px" }}>
点击目标节点的右端口完成连线
</div>
)}
</div>
{/* Config panel */}
<div style={{ width: "280px", background: "#16162a", borderLeft: "1px solid #2a2a4a", padding: "16px", overflowY: "auto" }}>
{selectedNode ? (
<>
<div style={{ fontSize: "14px", fontWeight: 600, color: "#fff", marginBottom: "4px" }}>
{NODE_LABELS[selectedNode.type] || selectedNode.type}
</div>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ fontSize: "12px", color: "#888" }}>显示名称</label>
<input
value={selectedNode.data.label}
onChange={e => {
const updated = currentWf.nodes.map(n => n.id === selectedNode.id ? { ...n, data: { ...n.data, label: e.target.value } } : n);
setCurrentWf({ ...currentWf, nodes: updated });
setSelectedNode({ ...selectedNode, data: { ...selectedNode.data, label: e.target.value } });
}}
style={configInput}
/>
</div>
{/* Config fields */}
{Object.entries(templates.find(t => t.type === selectedNode.type)?.config_schema || {}).map(([key, schema]: [string, any]) => (
<div key={key} style={{ marginBottom: "12px" }}>
<label style={{ fontSize: "12px", color: "#888" }}>{schema.label || key}</label>
{schema.help && <div style={{ fontSize: "11px", color: "#666", marginBottom: "2px" }}>{schema.help}</div>}
{schema.type === "textarea" ? (
<textarea
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={{ ...configInput, minHeight: "60px", resize: "vertical" }}
/>
) : schema.type === "select" && schema.options_from ? (
<select
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={configInput}
>
<option value="">-- 选择 --</option>
{(options[schema.options_from] || []).map((opt: any) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : schema.type === "select" && schema.options ? (
<select
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={configInput}
>
<option value="">-- 选择 --</option>
{schema.options.map((opt: string) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={schema.type === "number" ? "number" : "text"}
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, schema.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value)}
style={configInput}
/>
)}
</div>
))}
</>
) : (
<div style={{ color: "#666", fontSize: "13px", textAlign: "center", marginTop: "40px" }}>
选择一个节点查看配置
</div>
)}
</div>
</div>
</div>
);
}
// ============================================================
// Styles
// ============================================================
const btnPrimary: React.CSSProperties = {
background: "#534AB7", color: "#fff", border: "none", borderRadius: "6px",
padding: "6px 16px", cursor: "pointer", fontSize: "13px", fontWeight: 500,
};
const btnSmall: React.CSSProperties = {
background: "transparent", color: "#aaa", border: "1px solid #333", borderRadius: "4px",
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
};
const statCard: React.CSSProperties = {
background: "#1a1a2e", borderRadius: "8px", padding: "16px",
};
const configInput: React.CSSProperties = {
width: "100%", background: "#0d0d1a", border: "1px solid #333",
borderRadius: "4px", padding: "6px 8px", color: "#fff", fontSize: "13px",
outline: "none",
};
function statusBadge(status: string): React.CSSProperties {
const colors: Record<string, string> = {
published: "#3B6D11",
draft: "#BA7517",
archived: "#5F5E5A",
};
const bg = colors[status] || "#5F5E5A";
return {
background: `${bg}20`, color: bg, padding: "2px 8px",
borderRadius: "4px", fontSize: "12px",
};
}
+228
View File
@@ -0,0 +1,228 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { imUserAPI } from '@/api';
const WechatBot: React.FC = () => {
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<any>({ connected: false, configured: false });
const [qrcode, setQrcode] = useState<any>(null);
const [polling, setPolling] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// 加载当前用户的连接状态
const loadStatus = useCallback(async () => {
try {
const res = await imUserAPI.getStatus();
if ((res as any)?.success) setStatus(res.data || { connected: false, configured: false });
} catch (e) {
// 静默失败
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
// 获取扫码二维码(iLink 协议免 AppID)
const handleQrcode = async () => {
setLoading(true);
try {
const res = await imUserAPI.qrcode();
if ((res as any)?.success && (res as any).data) {
setQrcode((res as any).data);
setPolling(true);
showToast('二维码已生成,请用手机微信扫码');
} else {
showToast((res as any).error || '获取二维码失败', 'error');
}
} catch (e) {
showToast('获取二维码失败', 'error');
} finally {
setLoading(false);
}
};
// 轮询扫码状态
const handlePoll = useCallback(async () => {
if (!qrcode?.qrcode) return;
try {
const res = await imUserAPI.qrcodeStatus(qrcode.qrcode);
if ((res as any)?.success && (res as any).data) {
const d = (res as any).data;
if (d.status === 'confirmed' || d.connected) {
setPolling(false);
setReconnecting(false);
setStatus({ ...status, connected: true, configured: true });
showToast('个人微信已连接!');
loadStatus();
} else if (d.status === 'expired') {
setPolling(false);
showToast('二维码已过期,请重新获取', 'error');
}
}
} catch (e) {
// 忽略轮询错误
}
}, [qrcode, status, loadStatus]);
useEffect(() => {
if (polling && qrcode?.qrcode) {
const t = setInterval(() => handlePoll(), 3000);
return () => clearInterval(t);
}
}, [polling, qrcode, handlePoll]);
// 断开连接
const handleDisconnect = async () => {
try {
await imUserAPI.disconnect();
setPolling(false);
setQrcode(null);
setReconnecting(false);
setStatus({ connected: false, configured: false });
showToast('已断开个人微信连接');
loadStatus();
} catch (e) {
showToast('断开失败', 'error');
}
};
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>我的微信机器人</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
绑定你自己的个人微信,由七虎AI 大脑自动应答。基于腾讯官方 iLink 协议,扫码授权即可,无需任何开放平台凭证。
</p>
</div>
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
padding: '12px 24px', borderRadius: '8px',
background: toast.type === 'success' ? '#10b981' : '#ef4444',
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>{toast.msg}</div>
)}
{/* 状态条 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#F8F9FC', borderRadius: '12px', padding: '16px 24px', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: 0, fontSize: '16px', color: '#1A1F36' }}>个人微信(iLink 协议)</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#6B7280' }}>
直接对接腾讯官方 iLink Bot HTTP API,扫码授权后由七虎AI 自动回复。
</p>
</div>
<span style={{
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
background: status.connected ? '#10b98120' : status.status === 'expired' ? '#ef444420' : '#6b728020',
color: status.connected ? '#10b981' : status.status === 'expired' ? '#ef4444' : '#6b7280',
}}>
{status.connected ? '● 已连接' : status.status === 'expired' ? '⚠ 会话已过期' : status.configured ? '○ 已配置' : '○ 未绑定'}
</span>
</div>
{/* 扫码绑定 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
{status.status === 'expired' && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#b91c1c', borderRadius: '8px', padding: '12px 14px', marginBottom: '16px', fontSize: '13px', lineHeight: '1.7' }}>
⚠ <b>微信会话已失效</b>:检测到与微信的连接已过期(通常是服务更新/重启后导致),消息无法再送达。请点击下方「获取扫码二维码」<b>重新扫码授权</b>即可恢复,历史对话记录仍保留。
</div>
)}
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>扫码授权绑定</h4>
{status.connected && !reconnecting ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ fontSize: '13px', color: '#10b981' }}>已绑定,你的个人微信正在自动收发消息并由七虎AI 应答。</div>
<div>
<button onClick={() => { setReconnecting(true); handleQrcode(); }} style={secondaryBtnStyle}>重新连接(重扫二维码)</button>
</div>
<div style={{ fontSize: '12px', color: '#9CA3AF' }}>若发现消息收不到,可点此重新扫码以重建与微信的会话。</div>
</div>
) : !qrcode ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '12px' }}>
<div style={{ fontSize: '13px', color: '#6B7280' }}>
点击「获取扫码二维码」,使用手机微信扫描二维码并确认授权,即可将你的个人微信接入七虎AI。整个过程无需申请任何开放平台凭证。
</div>
<button onClick={handleQrcode} disabled={loading} style={primaryBtnStyle}>
{loading ? '生成中…' : '获取扫码二维码'}
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
{qrcode.qrcode_img ? (
<img src={qrcode.qrcode_img} alt="微信扫码" style={{ width: '220px', height: '220px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }} />
) : (
<div style={{ width: '220px', height: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', fontSize: '12px', color: '#9CA3AF', textAlign: 'center', padding: '12px' }}>
二维码图片生成失败,<a href={qrcode.qrcode_img_content} target="_blank" rel="noreferrer" style={{color:'#6C63FF'}}>请点击此处在新标签页打开</a>。
</div>
)}
<div style={{ fontSize: '13px', color: polling ? '#f59e0b' : '#6B7280' }}>
{polling ? '等待扫码中…(每 3 秒自动检测一次)' : '已停止轮询'}
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{polling && (
<button onClick={() => setPolling(false)} style={secondaryBtnStyle}>取消轮询</button>
)}
<button onClick={handleDisconnect} style={{ ...secondaryBtnStyle, color: '#ef4444', borderColor: '#ef4444' }}>断开连接</button>
</div>
</div>
)}
</div>
{/* 已连接信息 */}
{status.connected && (
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>连接信息</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<DetailItem label="绑定账号" value={status.username || '-'} />
<DetailItem label="连接时间" value={formatTime(status.connected_at)} />
<DetailItem label="iLink Bot ID" value={status.ilink_bot_id || '-'} />
<DetailItem label="Base URL" value={status.baseurl || '-'} />
</div>
</div>
)}
{/* 说明 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>接入说明</h4>
<ul style={{ fontSize: '13px', color: '#6B7280', lineHeight: '1.8', paddingLeft: '20px', margin: 0 }}>
<li>每个账号独立绑定自己的个人微信,数据严格按账号隔离,互不影响</li>
<li>个人微信通过腾讯官方 iLink Bot HTTP API(微信 ClawBot 插件)接入,消息经七虎AI 大脑生成回复</li>
<li>采用官方合法协议,无需 Hook / 逆向,封号风险远低于历史方案,但仍请遵守微信平台规范</li>
<li>消息收发基于长轮询(getupdates)+ 官方 sendmessage,回复自动关联对话窗口(context_token)</li>
</ul>
</div>
</div>
</div>
);
};
const DetailItem: React.FC<{ label: string; value: any }> = ({ label, value }) => (
<div style={{ padding: '8px 12px', background: '#FFFFFF', borderRadius: '6px' }}>
<div style={{ fontSize: '12px', color: '#666' }}>{label}</div>
<div style={{ fontSize: '14px', marginTop: '2px', wordBreak: 'break-all' }}>{value || '-'}</div>
</div>
);
const primaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
const secondaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#F0F2F8', border: '1px solid #F0F2F8', borderRadius: '6px',
color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
export default WechatBot;
@@ -0,0 +1,97 @@
// @ts-nocheck
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { imUserAPI } from '@/api';
const WechatHistory: React.FC = () => {
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState<any[]>([]);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await imUserAPI.history();
if ((res as any)?.success && (res as any).data) {
setMessages((res as any).data.messages || []);
} else {
showToast('加载失败', 'error');
}
} catch (e) {
showToast('加载失败', 'error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>微信对话记录</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
你通过个人微信与七虎AI 的对话历史,按时间顺序记录,可随时回看(仅本人可见,按账号隔离)。
</p>
</div>
<button onClick={load} disabled={loading} style={btnStyle}>{loading ? '刷新中…' : '刷新'}</button>
</div>
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
padding: '12px 24px', borderRadius: '8px',
background: toast.type === 'success' ? '#10b981' : '#ef4444',
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>{toast.msg}</div>
)}
{/* 记录列表 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '16px', minHeight: '420px' }}>
{messages.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '380px', color: '#9CA3AF', fontSize: '14px', textAlign: 'center', padding: '0 24px' }}>
{loading ? '加载中…' : '暂无对话记录。请先在「微信机器人」中绑定并发送消息,对话记录会显示在这里。'}
</div>
) : (
messages.map((m, i) => (
<div key={i} style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '14px' }}>
<div style={{ maxWidth: '78%' }}>
<div style={{ fontSize: '11px', color: '#9CA3AF', marginBottom: '4px', textAlign: m.role === 'user' ? 'right' : 'left' }}>
{m.role === 'user' ? '我(微信)' : '七虎AI'} · {formatTime(m.created_at)}
</div>
<div style={{
padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: '1.6',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
background: m.role === 'user' ? '#6C63FF' : '#FFFFFF',
color: m.role === 'user' ? '#fff' : '#1A1F36',
border: m.role === 'user' ? 'none' : '1px solid #E5E7EB',
}}>{m.content}</div>
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</div>
);
};
const btnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
export default WechatHistory;