This commit is contained in:
2026-07-22 16:56:42 +08:00
parent ed2e1715d1
commit 4fdbe0c946
6 changed files with 80 additions and 57 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-BMx1va3v.js"></script>
<script type="module" crossorigin src="/assets/index-ry1oBhYe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+45 -44
View File
@@ -361,55 +361,56 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
};
// ── 视频参数分布(紧凑饼图+列表)──
const PieBarChart: React.FC<{ data: { label: string; count: number }[] }> = ({ data }) => {
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
// 按模型分组
const modelMap = new Map<string, { label: string; count: number }[]>();
data.forEach(d => {
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
});
const models = Array.from(modelMap.entries());
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 style={{ height: 220, overflowY: 'auto' }}>
{models.map(([model, items], mi) => {
const modelTotal = items.reduce((s, it) => s + it.count, 0);
return (
<div key={model} style={{ marginBottom: 12 }}>
{/* 模型名称 + 总数 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
<span style={{ fontSize: 10, color: '#94a2b3' }}> {modelTotal} ({((modelTotal / total) * 100).toFixed(1)}%)</span>
</div>
);
})}
</div>
{/* 各参数 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{items.map((item, i) => (
<div key={i} style={{
flex: '0 0 calc(50% - 2px)',
padding: '4px 8px',
background: '#f8fafc',
borderRadius: 6,
border: '1px solid #f1f5f9',
transition: 'all 0.2s',
cursor: 'default',
}}
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
</div>
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
+1
View File
@@ -218,6 +218,7 @@ export interface ModelUsageOut {
}
export interface VideoParamOut {
model: string;
label: string;
count: number;
}
+31 -11
View File
@@ -1712,6 +1712,15 @@ async def list_operation_logs(
}
def _build_param_out(model_map: dict[str, dict[str, int]]) -> list[VideoParamOut]:
"""{模型: {标签: 数量}} 转为扁平列表,按模型+数量排序。"""
result: list[VideoParamOut] = []
for model, labels in model_map.items():
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
result.append(VideoParamOut(model=model, label=label, count=count))
return result
# ── Stats ────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsOut)
@@ -1951,11 +1960,18 @@ async def get_stats(
for row in model_usage_rows
]
# ── 视频分辨率/比例/时长使用分布(基于 ChatGenerationTask
# ── 视频分辨率/比例/时长使用分布(按模型分组
_video_gen_q = (
select(ChatGenerationTask.resolution, ChatGenerationTask.aspect_ratio, ChatGenerationTask.duration)
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model'),
ChatGenerationTask.resolution,
ChatGenerationTask.aspect_ratio,
ChatGenerationTask.duration,
)
.join(CreditRecord, CreditRecord.related_id == ChatGenerationTask.id)
.where(
ChatGenerationTask.gen_type == "video",
CreditRecord.type == "consume",
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
ChatGenerationTask.deleted_at.is_(None),
@@ -1963,21 +1979,25 @@ async def get_stats(
)
_video_rows = (await db.execute(_video_gen_q)).all()
_res_map: dict[str, int] = {}
_ratio_map: dict[str, int] = {}
_dur_map: dict[str, int] = {}
_res_map: dict[str, dict[str, int]] = {}
_ratio_map: dict[str, dict[str, int]] = {}
_dur_map: dict[str, dict[str, int]] = {}
for row in _video_rows:
model = row.model or '未知'
if row.resolution:
_res_map[row.resolution] = _res_map.get(row.resolution, 0) + 1
_res_map.setdefault(model, {})
_res_map[model][row.resolution] = _res_map[model].get(row.resolution, 0) + 1
if row.aspect_ratio:
_ratio_map[row.aspect_ratio] = _ratio_map.get(row.aspect_ratio, 0) + 1
_ratio_map.setdefault(model, {})
_ratio_map[model][row.aspect_ratio] = _ratio_map[model].get(row.aspect_ratio, 0) + 1
if row.duration:
_k = f"{row.duration}"
_dur_map[_k] = _dur_map.get(_k, 0) + 1
_dur_map.setdefault(model, {})
_dur_map[model][_k] = _dur_map[model].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])]
video_resolution_usage = _build_param_out(_res_map)
video_ratio_usage = _build_param_out(_ratio_map)
video_duration_usage = _build_param_out(_dur_map)
return AdminStatsOut(
total_users=total_users,
+1
View File
@@ -128,6 +128,7 @@ class ModelUsageOut(BaseModel):
class VideoParamOut(BaseModel):
model: str
label: str
count: int