后台概览页面修改

This commit is contained in:
2026-07-21 10:03:32 +08:00
parent 880e7ed515
commit 76d03e7de1
6 changed files with 370 additions and 411 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Ba0CxVyZ.js"></script>
<script type="module" crossorigin src="/assets/index-CkQa2uab.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+204 -354
View File
@@ -1,14 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
} from '@ant-design/icons';
import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats, SystemConfig } from '../types';
@@ -17,6 +11,16 @@ import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const MODULE_LABELS: Record<string, string> = {
'chat_ai': 'AI创作',
'hot_opening_replicate': '爆款开头',
'shot_replicate': '拆镜复刻',
'project': '项目生成',
'other': '其他',
};
const COLORS = ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#10b981', '#f59e0b', '#ef4444', '#3b82f6'];
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
@@ -39,371 +43,87 @@ const AdminDashboard: React.FC = () => {
const configs = await getSystemConfigs();
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
if (siteConfig) {
const title = `${siteConfig.value} 管理后台`;
setSiteName(title);
document.title = title;
setSiteName(`${siteConfig.value} 管理后台`);
document.title = `${siteConfig.value} 管理后台`;
}
} catch {
setSiteName('数据概览');
document.title = '数据概览';
}
} catch { /* ignore */ }
};
useEffect(() => {
load();
loadSiteName();
}, []);
const handleToday = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleYesterday = () => {
const yesterday = dayjs().subtract(1, 'day');
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
setStartDate(dates);
loadWithDates(dates);
};
const handleWeek = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleMonth = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
useEffect(() => { load(); loadSiteName(); }, []);
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
setStartDate(dates);
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
setLoading(true);
getAdminStats(start, end).then(data => {
setStats(data);
setLoading(false);
}).catch(() => {
setLoading(false);
});
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
};
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()]);
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')]); };
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()]);
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()]);
const handleDateChange = (dates: any) => {
if (dates) {
setStartDate([dates[0], dates[1]]);
loadWithDates([dates[0], dates[1]]);
}
};
const baseStats = stats ? [
{
title: '用户数量',
value: stats.totalUsers,
lastPeriodValue: stats.lastPeriodUsers,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
prefix: '',
suffix: '位用户',
description: '平台注册用户总数'
},
{
title: '总项目数',
value: stats.totalProjects,
lastPeriodValue: stats.lastPeriodProjects,
icon: <ProjectOutlined />,
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
prefix: '',
suffix: '个项目',
description: '创建的项目总数'
},
{
title: '项目记录',
value: stats.totalRecords,
lastPeriodValue: stats.lastPeriodRecords,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
prefix: '',
suffix: '条记录',
description: '项目记录总数'
},
{
title: '创作记录',
value: stats.totalGenerations,
lastPeriodValue: stats.lastPeriodGenerations,
icon: <PlayCircleOutlined />,
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
prefix: '',
suffix: '次创作',
description: 'AI创作记录总数'
},
] : [];
const financeStats = stats ? [
{
title: '支付宝收入',
value: stats.todayAlipayRevenue,
icon: <WalletOutlined />,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
prefix: '¥',
suffix: '',
description: '支付宝收款',
tag: '支付宝'
},
{
title: '微信收入',
value: stats.todayWechatRevenue,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
prefix: '¥',
suffix: '',
description: '微信收款',
tag: '微信支付'
},
{
title: '总收入',
value: stats.totalRevenue,
lastPeriodValue: stats.lastPeriodRevenue,
icon: <ArrowUpOutlined />,
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
prefix: '¥',
suffix: '',
description: '平台总收入',
tag: '总收入'
},
{
title: '消耗积分',
value: stats.creditsConsumedToday,
lastPeriodValue: stats.lastPeriodCreditsConsumed,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
prefix: '',
suffix: '积分',
description: '用户消耗积分',
tag: '积分消耗'
},
] : [];
const StatCard: React.FC<{
title: string;
value: number;
lastPeriodValue?: number;
icon: React.ReactNode;
gradient: string;
prefix?: string;
suffix?: string;
description?: string;
tag?: string;
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
: null;
const isPositive = change !== null && parseFloat(change) >= 0;
return (
<Card
bordered={false}
loading={loading}
hoverable
style={{
borderRadius: 16,
border: '1px solid rgba(0,0,0,0.04)',
background: '#ffffff',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
overflow: 'hidden'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 0'
}}>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
flexShrink: 0,
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 4
}}>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
{tag && (
<span style={{
fontSize: 10,
padding: '2px 8px',
borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
color: '#6366f1',
fontWeight: 500
}}>
{tag}
</span>
)}
</div>
<div style={{
fontSize: 28,
fontWeight: 800,
color: '#1e293b',
letterSpacing: -0.5,
marginBottom: 2
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
{description && (
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
{description}
</Typography.Text>
)}
{change !== null && lastPeriodValue !== undefined && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
</Typography.Text>
<span style={{
fontSize: 11,
fontWeight: 500,
color: isPositive ? '#10b981' : '#ef4444',
display: 'flex',
alignItems: 'center',
gap: 2
}}>
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
</span>
</div>
)}
</div>
</div>
</Card>
);
if (dates) loadWithDates([dates[0], dates[1]]);
};
return (
<div style={{ padding: 0 }}>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px 24px 32px',
borderRadius: 0,
marginBottom: -24,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%'
}} />
<div style={{
position: 'absolute',
bottom: -30,
left: -30,
width: 150,
height: 150,
background: 'rgba(255,255,255,0.08)',
borderRadius: '50%'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
{siteName}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
</Typography.Text>
<div>
{/* 日期筛选 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
<Space>
<Button type="primary" size="small" onClick={handleToday}></Button>
<Button size="small" onClick={handleYesterday}></Button>
<Button size="small" onClick={handleWeek}></Button>
<Button size="small" onClick={handleMonth}></Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
<CalendarOutlined style={{ color: '#64748b' }} />
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
</div>
</div>
<div style={{ marginTop: 40 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<Space>
<Button
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
size="small"
onClick={handleToday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleYesterday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleWeek}
>
</Button>
<Button
type="default"
size="small"
onClick={handleMonth}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
<DatePicker.RangePicker
value={startDate}
onChange={handleDateChange}
placeholder={['开始日期', '结束日期']}
size="small"
/>
</div>
</div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{baseStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
</Row>
</div>
{/* 图表区域 */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<ChartCard title="每日积分消耗趋势" loading={loading}>
<LineChart data={stats?.dailyCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="各模块积分占比" loading={loading}>
<ModulePie data={stats?.dailyCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="团队积分消耗排行" loading={loading}>
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="模型使用次数" loading={loading}>
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
</ChartCard>
</Col>
</Row>
{/* 核心数据 */}
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{financeStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
<Row gutter={[12, 12]}>
{[
{ title: '用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
{ title: '总项目数', value: stats?.totalProjects, icon: <ProjectOutlined />, color: '#3b82f6' },
{ title: '项目记录', value: stats?.totalRecords, icon: <FileTextOutlined />, color: '#10b981' },
{ title: '创作记录', value: stats?.totalGenerations, icon: <PlayCircleOutlined />, color: '#f59e0b' },
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
].map(s => (
<Col xs={12} sm={8} md={4} key={s.title}>
<CompactStatCard {...s} loading={loading} />
</Col>
))}
</Row>
@@ -412,4 +132,134 @@ const AdminDashboard: React.FC = () => {
);
};
// ── 图表卡片 ──
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', height: '100%' }}
styles={{ body: { padding: '16px' } }}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
</div>
<Spin spinning={loading}>{children}</Spin>
</Card>
);
// ── 核心数据小卡片 ──
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
styles={{ body: { padding: '12px 14px' } }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
{icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
</div>
</div>
</div>
</Card>
);
// ── 折线图(按日期+模块)──
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
// 按日期聚合
const dateMap = new Map<string, number>();
const moduleSet = new Set<string>();
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); moduleSet.add(d.module); });
const dates = Array.from(dateMap.keys()).sort();
const maxVal = Math.max(...dateMap.values(), 1);
const maxPoints = 14;
const step = Math.max(1, Math.floor(dates.length / maxPoints));
const sampled = dates.filter((_, i) => i % step === 0 || i === dates.length - 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 2, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
{sampled.map(d => {
const val = dateMap.get(d) || 0;
const pct = (val / maxVal) * 100;
return (
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end' }}>
<div style={{ width: '70%', maxWidth: 28, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '3px 3px 0 0', minHeight: 3, transition: 'height 0.3s' }} />
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
{sampled.map(d => (
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
</div>
))}
</div>
</div>
);
};
// ── 模块积分占比(堆叠式横条)──
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
const moduleMap = new Map<string, number>();
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 10 }}>
{/* 堆叠条 */}
<div style={{ height: 28, borderRadius: 6, display: 'flex', overflow: 'hidden', background: '#f1f5f9' }}>
{modules.map(([mod, val], i) => (
<div key={mod} style={{ width: `${(val / total) * 100}%`, background: COLORS[i % COLORS.length], minWidth: val > 0 ? 4 : 0, transition: 'width 0.3s' }} />
))}
</div>
{/* 图例 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px' }}>
{modules.map(([mod, val], i) => (
<div key={mod} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: '#64748b' }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[i % COLORS.length] }} />
<span>{MODULE_LABELS[mod] || mod}</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>{val.toFixed(0)}</span>
<span style={{ fontSize: 10 }}>({((val / total) * 100).toFixed(1)}%)</span>
</div>
))}
</div>
</div>
);
};
// ── 横向柱状图(团队/模型)──
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
if (!data.length) return <EmptyChart />;
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}>
{data.map((d, i) => {
const label = d.teamName || d.modelName || '-';
const val = (d as any)[valueKey] || 0;
const pct = (val / maxVal) * 100;
return (
<div key={i}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#1e293b' }}>{val.toLocaleString()}</span>
</div>
<div style={{ height: 16, background: '#f1f5f9', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${COLORS[i % COLORS.length]}, ${COLORS[(i + 1) % COLORS.length]})`, borderRadius: 4, transition: 'width 0.3s' }} />
</div>
</div>
);
})}
</div>
);
};
const EmptyChart = () => (
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
</div>
);
export default AdminDashboard;
+21
View File
@@ -199,6 +199,24 @@ export interface AdminUser {
privatePortraitAssetLimit: number;
}
export interface DailyCredit {
date: string;
module: string;
credits: number;
}
export interface TeamCredit {
teamName: string;
teamId: string | null;
credits: number;
}
export interface ModelUsageOut {
modelName: string;
provider: string;
count: number;
}
export interface AdminStats {
totalUsers: number;
totalProjects: number;
@@ -214,6 +232,9 @@ export interface AdminStats {
lastPeriodRecords: number;
lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number;
dailyCreditsByModule: DailyCredit[];
creditsByTeam: TeamCredit[];
modelUsage: ModelUsageOut[];
}
export interface PaymentStats {
+66
View File
@@ -34,6 +34,9 @@ from app.schemas.admin import (
SystemConfigOut,
AdminUserOut,
AdminStatsOut,
DailyCreditOut,
TeamCreditOut,
ModelUsageOut,
CreateUserRequest,
UpdateMenusRequest,
ResetPasswordRequest,
@@ -1853,6 +1856,66 @@ async def get_stats(
)
)).scalar() or 0
# ── 每日各模块积分消耗(按日期+模块分组)
daily_credits_rows = (await db.execute(
select(
func.to_char(CreditRecord.created_at, 'YYYY-MM-DD').label('date'),
func.coalesce(CreditRecord.source_module, 'other').label('module'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(func.to_char(CreditRecord.created_at, 'YYYY-MM-DD'), func.coalesce(CreditRecord.source_module, 'other'))
.order_by(func.to_char(CreditRecord.created_at, 'YYYY-MM-DD'))
)).all()
daily_credits_by_module = [
DailyCreditOut(date=row.date, module=row.module, credits=float(row.credits or 0))
for row in daily_credits_rows
]
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
team_credit_rows = (await db.execute(
select(
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
CreditRecord.team_id_snapshot.label('team_id'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
)).all()
credits_by_team = [
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
for row in team_credit_rows
]
# ── 各模型使用次数(通过 engine 快照字段统计)
model_usage_rows = (await db.execute(
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model_name'),
func.coalesce(CreditRecord.engine_provider, 'unknown').label('provider'),
func.count(CreditRecord.id).label('count'),
)
.where(
CreditRecord.type == "consume",
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.engine_name, CreditRecord.engine_provider)
.order_by(func.count(CreditRecord.id).desc())
)).all()
model_usage = [
ModelUsageOut(model_name=row.model_name, provider=row.provider, count=int(row.count or 0))
for row in model_usage_rows
]
return AdminStatsOut(
total_users=total_users,
total_projects=total_projects,
@@ -1868,6 +1931,9 @@ async def get_stats(
last_period_records=last_period_records,
last_period_revenue=float(last_period_revenue),
last_period_credits_consumed=float(last_period_credits_consumed),
daily_credits_by_module=daily_credits_by_module,
credits_by_team=credits_by_team,
model_usage=model_usage,
)
+22
View File
@@ -109,6 +109,24 @@ class OperationLogOut(BaseModel):
model_config = {"from_attributes": True}
class DailyCreditOut(BaseModel):
date: str
module: str
credits: float
class TeamCreditOut(BaseModel):
team_name: str
team_id: str | None
credits: float
class ModelUsageOut(BaseModel):
model_name: str
provider: str
count: int
class AdminStatsOut(BaseModel):
total_users: int
total_projects: int
@@ -124,6 +142,10 @@ class AdminStatsOut(BaseModel):
last_period_records: int = 0
last_period_revenue: float = 0.0
last_period_credits_consumed: float = 0.0
# 图表数据
daily_credits_by_module: list[DailyCreditOut] = []
credits_by_team: list[TeamCreditOut] = []
model_usage: list[ModelUsageOut] = []
class AdminCreditRecordSummaryOut(BaseModel):