fix: scrolling, responsive grid, analytics light theme, dashboard 2x3, sidebar spacing, skills top5
This commit is contained in:
1 parent
c729f61831
commit
4d9691187a
5 files changed
+130
-87
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
/* ===== Admin Layout - Light Theme V5 ===== */
|
/* ===== Admin Layout - Light Theme V5 ===== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--admin-sidebar-width: 240px;
|
--admin-sidebar-width: 220px;
|
||||||
--sidebar-collapsed: 64px;
|
--sidebar-collapsed: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
.admin-sidebar-header {
|
.admin-sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 14px 16px 10px;
|
padding: 12px 16px 8px;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 4px 8px;
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav::-webkit-scrollbar {
|
.sidebar-nav::-webkit-scrollbar {
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
|
|
||||||
/* Nav Group */
|
/* Nav Group */
|
||||||
.nav-group {
|
.nav-group {
|
||||||
margin-bottom: 0;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group-header {
|
.nav-group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 7px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
padding: 2px 0 4px 0;
|
padding: 1px 0 3px 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 10px 7px 30px;
|
padding: 6px 10px 6px 28px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #5A6480;
|
color: #5A6480;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -882,30 +882,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Responsive ========== */
|
/* ========== Responsive ========== */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.admin-content { padding: 16px 20px; }
|
||||||
|
.admin-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.admin-stats-responsive { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-sidebar {
|
.admin-sidebar { position: fixed; left: -100%; top: 0; bottom: 0; transition: left 0.3s ease; z-index: 1000; }
|
||||||
position: fixed;
|
.admin-sidebar.mobile-open { left: 0; }
|
||||||
left: -100%;
|
.admin-content { padding: 12px; }
|
||||||
top: 0;
|
.admin-stats { grid-template-columns: 1fr; }
|
||||||
bottom: 0;
|
.admin-stats-responsive { grid-template-columns: 1fr; }
|
||||||
transition: left 0.3s ease;
|
.admin-modal { min-width: auto; width: 90vw; max-width: 90vw; }
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar.mobile-open {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-modal {
|
|
||||||
min-width: auto;
|
|
||||||
width: 90vw;
|
|
||||||
max-width: 90vw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ const Analytics: React.FC = () => {
|
|||||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||||
|
|
||||||
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
||||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||||
const values = data.map((d: any) => d[key] || 0);
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
return (
|
return (
|
||||||
@@ -54,9 +54,9 @@ const Analytics: React.FC = () => {
|
|||||||
const h = (v / max) * 140;
|
const h = (v / max) * 140;
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
||||||
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
<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={{ 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: '#64748b', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -65,7 +65,7 @@ const Analytics: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
||||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||||
const values = data.map((d: any) => d[key] || 0);
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
const w = 100 / (data.length - 1 || 1);
|
const w = 100 / (data.length - 1 || 1);
|
||||||
@@ -81,7 +81,7 @@ const Analytics: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statCards = overview ? [
|
const statCards = overview ? [
|
||||||
{ label: '总用户数', value: overview.total_users, color: '#6366f1', icon: 'U' },
|
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
|
||||||
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
||||||
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
||||||
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
||||||
@@ -92,90 +92,90 @@ const Analytics: React.FC = () => {
|
|||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
<div style={{ padding: '24px', color: '#1A1F36' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
{[7, 14, 30, 90].map(d => (
|
{[7, 14, 30, 90].map(d => (
|
||||||
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||||
background: days === d ? '#6366f1' : '#1e293b', color: days === d ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#64748b' }}>加载中...</div> : (
|
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
|
||||||
<>
|
<>
|
||||||
{/* Stats grid */}
|
{/* Stats grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
{statCards.map((s, i) => (
|
{statCards.map((s, i) => (
|
||||||
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<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 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>
|
||||||
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</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: '#94a3b8', marginTop: '2px' }}>{s.label}</div>
|
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts grid */}
|
{/* Charts grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
||||||
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
||||||
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
||||||
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
||||||
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model usage */}
|
{/* Model usage */}
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
||||||
{modelUsage.length > 0 ? (
|
{modelUsage.length > 0 ? (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead><tr style={{ borderBottom: '2px solid #334155' }}>
|
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
|
||||||
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
||||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{modelUsage.map((m, i) => (
|
{modelUsage.map((m, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #334155' }}>
|
<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', 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' }}>{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: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.avg_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>
|
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无模型使用数据</div>}
|
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model distribution pie (CSS) */}
|
{/* Model distribution pie (CSS) */}
|
||||||
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
||||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const entries = Object.entries(tokenTrend.model_distribution);
|
const entries = Object.entries(tokenTrend.model_distribution);
|
||||||
const total = entries.reduce((s, [, v]) => s + v, 1);
|
const total = entries.reduce((s, [, v]) => s + v, 1);
|
||||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
return entries.map(([model, val], i) => {
|
return entries.map(([model, val], i) => {
|
||||||
const pct = (val / total) * 100;
|
const pct = (val / total) * 100;
|
||||||
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
||||||
@@ -184,13 +184,13 @@ const Analytics: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
||||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
||||||
return (
|
return (
|
||||||
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
<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] }} />
|
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
||||||
<span style={{ fontSize: '13px', color: '#e2e8f0' }}>{model}</span>
|
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
|
||||||
<span style={{ fontSize: '13px', color: '#94a3b8', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<h1 className="admin-page-title">平台驾驶舱</h1>
|
<h1 className="admin-page-title">平台驾驶舱</h1>
|
||||||
|
|
||||||
{/* Stats Cards Row */}
|
{/* Stats Cards Row */}
|
||||||
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
{statCardDefs.map((s, i) => (
|
{statCardDefs.map((s, i) => (
|
||||||
<div key={i} className="admin-stat-card" style={{
|
<div key={i} className="admin-stat-card" style={{
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
||||||
@@ -230,7 +230,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row */}
|
{/* Charts Row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
||||||
{/* Token Trend Chart */}
|
{/* Token Trend Chart */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
@@ -277,7 +277,7 @@ const Dashboard: React.FC = () => {
|
|||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
{quickEntries.map((entry) => (
|
{quickEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ const LandingPage: React.FC = () => {
|
|||||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
const banners = mockAdBanners.filter(b => b.status === 'active');
|
||||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||||
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
||||||
|
const [skillData, setSkillData] = useState<{skills: any[], agents: any[], mcps: any[]}>({skills: [], agents: [], mcps: []});
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const apiModule = await import('@/api');
|
||||||
|
const res = await apiModule.adminAPI.getAdminSkills({page: 1, page_size: 50, status: 'published'});
|
||||||
|
const allApps: any[] = (res as any)?.data?.items || (res as any)?.data || [];
|
||||||
|
const sortFn = (a: any, b: any) => (b.usage_count || b.install_count || 0) - (a.usage_count || a.install_count || 0);
|
||||||
|
const skills = allApps.filter(a => a.type === 'skill').sort(sortFn).slice(0, 5);
|
||||||
|
const agents = allApps.filter(a => a.type === 'agent').sort(sortFn).slice(0, 5);
|
||||||
|
const mcps = allApps.filter(a => a.type === 'mcp').sort(sortFn).slice(0, 5);
|
||||||
|
if (skills.length + agents.length + mcps.length === 0) {
|
||||||
|
setSkillData({skills: publishedApps.filter(a => a.type === 'skill').slice(0, 5), agents: publishedApps.filter(a => a.type === 'agent').slice(0, 5), mcps: publishedApps.filter(a => a.type === 'mcp').slice(0, 5)});
|
||||||
|
} else { setSkillData({skills, agents, mcps}); }
|
||||||
|
} catch (e) {
|
||||||
|
setSkillData({skills: publishedApps.filter(a => a.type === 'skill').slice(0, 5), agents: publishedApps.filter(a => a.type === 'agent').slice(0, 5), mcps: publishedApps.filter(a => a.type === 'mcp').slice(0, 5)});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
const [toastMsg, setToastMsg] = useState('');
|
const [toastMsg, setToastMsg] = useState('');
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
@@ -373,27 +392,43 @@ const LandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="market-grid-v2">
|
<div className="market-grid-v2">
|
||||||
{filteredApps.slice(0, 6).map(app => (
|
{(() => {
|
||||||
<div key={app.id} className="market-card-v2" onClick={() => handleAppClick(app.name)}>
|
const hasRealData = skillData.skills.length + skillData.agents.length + skillData.mcps.length > 0;
|
||||||
<div className="market-card-icon-v2" style={{ background: getGradient(app) }}>
|
let displayApps: any[];
|
||||||
{app.name.charAt(0)}
|
if (appTypeFilter) {
|
||||||
</div>
|
const key = appTypeFilter === 'skill' ? 'skills' : appTypeFilter === 'agent' ? 'agents' : 'mcps';
|
||||||
<div className="market-card-body-v2">
|
displayApps = hasRealData ? skillData[key] : filteredApps.slice(0, 5);
|
||||||
<div className="market-card-header-v2">
|
} else {
|
||||||
<h4>{app.name}</h4>
|
displayApps = hasRealData ? [...skillData.skills, ...skillData.agents, ...skillData.mcps] : filteredApps.slice(0, 6);
|
||||||
<span className="type-badge" style={{ background: typeColors[app.type], color: '#fff' }}>
|
}
|
||||||
{typeLabels[app.type]}
|
return displayApps.map((app: any) => {
|
||||||
</span>
|
const appType = app.type || 'skill';
|
||||||
|
const appColor = typeColors[appType] || '#6C63FF';
|
||||||
|
const appLabel = typeLabels[appType] || 'Skill';
|
||||||
|
const appName = app.name || app.title || '未命名';
|
||||||
|
const appDesc = app.description || app.desc || '';
|
||||||
|
const appUsage = app.usage_count || app.install_count || 0;
|
||||||
|
const appPrice = app.price || 0;
|
||||||
|
return (
|
||||||
|
<div key={app.id || appName} className="market-card-v2" onClick={() => handleAppClick(appName)}>
|
||||||
|
<div className="market-card-icon-v2" style={{ background: hasRealData ? `linear-gradient(135deg, ${appColor}44, ${appColor}11)` : getGradient(app) }}>
|
||||||
|
<span style={{ color: appColor, fontWeight: 700, fontSize: 16 }}>{appName.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="market-card-body-v2">
|
||||||
|
<div className="market-card-header-v2">
|
||||||
|
<h4>{appName}</h4>
|
||||||
|
<span className="type-badge" style={{ background: appColor, color: '#fff' }}>{appLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="market-card-desc-v2">{appDesc}</p>
|
||||||
|
<div className="market-card-meta-v2">
|
||||||
|
{appUsage > 0 && <span>📥 {appUsage >= 1000 ? `${(appUsage/1000).toFixed(1)}k` : appUsage}次</span>}
|
||||||
|
<span>{appPrice === 0 ? '免费' : `¥${appPrice}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="market-card-desc-v2">{app.description}</p>
|
);
|
||||||
<div className="market-card-meta-v2">
|
});
|
||||||
<span>⭐ 4.8</span>
|
})()}
|
||||||
<span>📥 1.2k</span>
|
|
||||||
<span>{app.price === 0 ? '免费' : `¥${app.price}`}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -123,13 +123,11 @@ body {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow: hidden;
|
min-height: 100vh;
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -745,3 +743,27 @@ a:hover {
|
|||||||
border-color: rgba(108, 99, 255, 0.2) !important;
|
border-color: rgba(108, 99, 255, 0.2) !important;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08) !important;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive Grid Patterns ===== */
|
||||||
|
.responsive-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
|
@media (max-width: 768px) { .responsive-grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
.responsive-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||||
|
@media (max-width: 1024px) { .responsive-grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 768px) { .responsive-grid-3 { grid-template-columns: 1fr; } }
|
||||||
|
.responsive-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||||
|
@media (max-width: 1200px) { .responsive-grid-4 { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (max-width: 900px) { .responsive-grid-4 { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 600px) { .responsive-grid-4 { grid-template-columns: 1fr; } }
|
||||||
|
.responsive-grid-auto { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
@media (max-width: 600px) { .responsive-grid-auto { grid-template-columns: 1fr; } }
|
||||||
|
.responsive-flex { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
|
.responsive-flex > * { flex: 1 1 280px; min-width: 0; }
|
||||||
|
.pagination-bar { display: flex; align-items: center; justify-content: center; gap: 4px; margin-top: 20px; padding: 8px 0; }
|
||||||
|
.pagination-bar button { min-width: 32px; height: 32px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.10); background: #FFFFFF; color: #5A6480; font-size: 13px; cursor: pointer; transition: all 0.15s ease; padding: 0 8px; }
|
||||||
|
.pagination-bar button:hover { background: #F0F2F8; color: #1A1F36; }
|
||||||
|
.pagination-bar button.active { background: #6C63FF; border-color: #6C63FF; color: #FFFFFF; }
|
||||||
|
.pagination-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.pagination-bar .page-info { font-size: 12px; color: #8892B0; margin: 0 8px; }
|
||||||
|
.admin-stats-responsive { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
@media (max-width: 900px) { .admin-stats-responsive { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 600px) { .admin-stats-responsive { grid-template-columns: 1fr; } }
|
||||||
Reference in new issue
Block a user