1
This commit is contained in:
Vendored
+78
-78
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CnO3FgtX.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BMx1va3v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -136,13 +136,37 @@ const AdminDashboard: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 视频参数分布 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>视频参数分布</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="分辨率分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoResolutionUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="画面比例分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoRatioUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="时长分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoDurationUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 图表卡片 ──
|
||||
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%' }}
|
||||
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
|
||||
styles={{ body: { padding: '16px' } }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
|
||||
@@ -186,19 +210,35 @@ const LineChart: React.FC<{ data: { date: string; module: string; credits: numbe
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 4, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||
{sevenDays.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 key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
|
||||
onMouseEnter={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
|
||||
if (tip) { tip.style.opacity = '1'; }
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
|
||||
if (tip) { tip.style.opacity = '0'; }
|
||||
}}
|
||||
>
|
||||
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
|
||||
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
|
||||
</div>
|
||||
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
|
||||
<div style={{ width: '65%', maxWidth: 32, 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 className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
{sevenDays.map(d => (
|
||||
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
|
||||
@@ -243,9 +283,27 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
|
||||
<defs>
|
||||
{slices.map((s, i) => (
|
||||
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{slices.map((s, i) => (
|
||||
<path key={i} d={s.d} fill={s.color} stroke="#fff" strokeWidth={2} />
|
||||
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
|
||||
onMouseEnter={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
|
||||
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1)';
|
||||
(e.target as SVGPathElement).style.filter = 'none';
|
||||
}}
|
||||
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
<circle cx={cx} cy={cy} r={36} fill="#fff" />
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b">总计</text>
|
||||
@@ -253,8 +311,11 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
|
||||
</svg>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{slices.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b' }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0 }} />
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
|
||||
<span>{s.label}</span>
|
||||
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
|
||||
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
|
||||
@@ -271,19 +332,26 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
|
||||
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' }}>
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{data.map((d, i) => {
|
||||
const label = d.teamName || d.modelName || '-';
|
||||
const val = (d as any)[valueKey] || 0;
|
||||
const pct = (val / maxVal) * 100;
|
||||
const c1 = COLORS[i % COLORS.length];
|
||||
const c2 = COLORS[(i + 1) % COLORS.length];
|
||||
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 key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, 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 style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -292,7 +360,61 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChart = () => (
|
||||
// ── 视频参数分布(紧凑饼图+列表)──
|
||||
const PieBarChart: React.FC<{ data: { label: string; count: number }[] }> = ({ data }) => {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||
const maxVal = Math.max(...data.map(d => d.count), 1);
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* 迷你饼图 */}
|
||||
<svg width={90} height={90} viewBox="0 0 90 90" style={{ flexShrink: 0, filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.08))' }}>
|
||||
{(() => {
|
||||
const cx = 45, cy = 45, r = 38;
|
||||
let cum = -90;
|
||||
return data.map((d, i) => {
|
||||
const angle = (d.count / total) * 360;
|
||||
const start = cum; cum += angle;
|
||||
const end = cum;
|
||||
const sr = (start * Math.PI) / 180, er = (end * Math.PI) / 180;
|
||||
const x1 = cx + r * Math.cos(sr), y1 = cy + r * Math.sin(sr);
|
||||
const x2 = cx + r * Math.cos(er), y2 = cy + r * Math.sin(er);
|
||||
const la = angle > 180 ? 1 : 0;
|
||||
return <path key={i} d={`M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${la} 1 ${x2},${y2} Z`} fill={COLORS[i % COLORS.length]} stroke="#fff" strokeWidth={1.5} style={{ transition: 'transform 0.25s', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
||||
onMouseEnter={e => { (e.target as SVGPathElement).style.transform = 'scale(1.08)'; }}
|
||||
onMouseLeave={e => { (e.target as SVGPathElement).style.transform = 'scale(1)'; }}
|
||||
/>;
|
||||
});
|
||||
})()}
|
||||
<circle cx={45} cy={45} r={20} fill="#fff" />
|
||||
<text x={45} y={48} textAnchor="middle" fontSize={12} fontWeight={700} fill="#1e293b">{total}</text>
|
||||
</svg>
|
||||
{/* 列表 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto', maxHeight: 200 }}>
|
||||
{data.map((d, i) => {
|
||||
const pct = (d.count / maxVal) * 100;
|
||||
return (
|
||||
<div key={i} style={{ padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{d.label}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{d.count} <span style={{ fontSize: 9, color: '#94a3b8' }}>({((d.count / total) * 100).toFixed(1)}%)</span></span>
|
||||
</div>
|
||||
<div style={{ height: 8, background: '#f1f5f9', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: COLORS[i % COLORS.length], borderRadius: 4, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChart: React.FC = () => (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
暂无数据
|
||||
</div>
|
||||
|
||||
@@ -217,6 +217,11 @@ export interface ModelUsageOut {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VideoParamOut {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
totalProjects: number;
|
||||
@@ -236,6 +241,9 @@ export interface AdminStats {
|
||||
periodCreditsByModule: DailyCredit[];
|
||||
creditsByTeam: TeamCredit[];
|
||||
modelUsage: ModelUsageOut[];
|
||||
videoResolutionUsage: VideoParamOut[];
|
||||
videoRatioUsage: VideoParamOut[];
|
||||
videoDurationUsage: VideoParamOut[];
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.admin import (
|
||||
DailyCreditOut,
|
||||
TeamCreditOut,
|
||||
ModelUsageOut,
|
||||
VideoParamOut,
|
||||
CreateUserRequest,
|
||||
UpdateMenusRequest,
|
||||
ResetPasswordRequest,
|
||||
@@ -1950,6 +1951,34 @@ async def get_stats(
|
||||
for row in model_usage_rows
|
||||
]
|
||||
|
||||
# ── 视频分辨率/比例/时长使用分布(基于 ChatGenerationTask)
|
||||
_video_gen_q = (
|
||||
select(ChatGenerationTask.resolution, ChatGenerationTask.aspect_ratio, ChatGenerationTask.duration)
|
||||
.where(
|
||||
ChatGenerationTask.gen_type == "video",
|
||||
ChatGenerationTask.created_at >= date_start,
|
||||
ChatGenerationTask.created_at <= date_end,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
_video_rows = (await db.execute(_video_gen_q)).all()
|
||||
|
||||
_res_map: dict[str, int] = {}
|
||||
_ratio_map: dict[str, int] = {}
|
||||
_dur_map: dict[str, int] = {}
|
||||
for row in _video_rows:
|
||||
if row.resolution:
|
||||
_res_map[row.resolution] = _res_map.get(row.resolution, 0) + 1
|
||||
if row.aspect_ratio:
|
||||
_ratio_map[row.aspect_ratio] = _ratio_map.get(row.aspect_ratio, 0) + 1
|
||||
if row.duration:
|
||||
_k = f"{row.duration}秒"
|
||||
_dur_map[_k] = _dur_map.get(_k, 0) + 1
|
||||
|
||||
video_resolution_usage = [VideoParamOut(label=k, count=v) for k, v in sorted(_res_map.items(), key=lambda x: -x[1])]
|
||||
video_ratio_usage = [VideoParamOut(label=k, count=v) for k, v in sorted(_ratio_map.items(), key=lambda x: -x[1])]
|
||||
video_duration_usage = [VideoParamOut(label=k, count=v) for k, v in sorted(_dur_map.items(), key=lambda x: -x[1])]
|
||||
|
||||
return AdminStatsOut(
|
||||
total_users=total_users,
|
||||
total_projects=total_projects,
|
||||
@@ -1969,6 +1998,9 @@ async def get_stats(
|
||||
period_credits_by_module=period_credits_by_module,
|
||||
credits_by_team=credits_by_team,
|
||||
model_usage=model_usage,
|
||||
video_resolution_usage=video_resolution_usage,
|
||||
video_ratio_usage=video_ratio_usage,
|
||||
video_duration_usage=video_duration_usage,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,11 @@ class ModelUsageOut(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class VideoParamOut(BaseModel):
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class AdminStatsOut(BaseModel):
|
||||
total_users: int
|
||||
total_projects: int
|
||||
@@ -147,6 +152,9 @@ class AdminStatsOut(BaseModel):
|
||||
period_credits_by_module: list[DailyCreditOut] = []
|
||||
credits_by_team: list[TeamCreditOut] = []
|
||||
model_usage: list[ModelUsageOut] = []
|
||||
video_resolution_usage: list[VideoParamOut] = []
|
||||
video_ratio_usage: list[VideoParamOut] = []
|
||||
video_duration_usage: list[VideoParamOut] = []
|
||||
|
||||
|
||||
class AdminCreditRecordSummaryOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user