1
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -361,56 +361,57 @@ 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 />;
|
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 total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||||
const maxVal = Math.max(...data.map(d => d.count), 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ height: 220, overflowY: 'auto' }}>
|
||||||
{/* 迷你饼图 */}
|
{models.map(([model, items], mi) => {
|
||||||
<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 modelTotal = items.reduce((s, it) => s + it.count, 0);
|
||||||
{(() => {
|
|
||||||
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 (
|
return (
|
||||||
<div key={i} style={{ padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s' }}
|
<div key={model} style={{ marginBottom: 12 }}>
|
||||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; }}
|
{/* 模型名称 + 总数 */}
|
||||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
>
|
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
|
<span style={{ fontSize: 10, color: '#94a2b3' }}>共 {modelTotal} 条 ({((modelTotal / total) * 100).toFixed(1)}%)</span>
|
||||||
<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>
|
||||||
<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 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export interface ModelUsageOut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoParamOut {
|
export interface VideoParamOut {
|
||||||
|
model: string;
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
# ── Stats ────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/stats", response_model=AdminStatsOut)
|
@router.get("/stats", response_model=AdminStatsOut)
|
||||||
@@ -1951,11 +1960,18 @@ async def get_stats(
|
|||||||
for row in model_usage_rows
|
for row in model_usage_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── 视频分辨率/比例/时长使用分布(基于 ChatGenerationTask)
|
# ── 视频分辨率/比例/时长使用分布(按模型分组)
|
||||||
_video_gen_q = (
|
_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(
|
.where(
|
||||||
ChatGenerationTask.gen_type == "video",
|
ChatGenerationTask.gen_type == "video",
|
||||||
|
CreditRecord.type == "consume",
|
||||||
ChatGenerationTask.created_at >= date_start,
|
ChatGenerationTask.created_at >= date_start,
|
||||||
ChatGenerationTask.created_at <= date_end,
|
ChatGenerationTask.created_at <= date_end,
|
||||||
ChatGenerationTask.deleted_at.is_(None),
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
@@ -1963,21 +1979,25 @@ async def get_stats(
|
|||||||
)
|
)
|
||||||
_video_rows = (await db.execute(_video_gen_q)).all()
|
_video_rows = (await db.execute(_video_gen_q)).all()
|
||||||
|
|
||||||
_res_map: dict[str, int] = {}
|
_res_map: dict[str, dict[str, int]] = {}
|
||||||
_ratio_map: dict[str, int] = {}
|
_ratio_map: dict[str, dict[str, int]] = {}
|
||||||
_dur_map: dict[str, int] = {}
|
_dur_map: dict[str, dict[str, int]] = {}
|
||||||
for row in _video_rows:
|
for row in _video_rows:
|
||||||
|
model = row.model or '未知'
|
||||||
if row.resolution:
|
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:
|
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:
|
if row.duration:
|
||||||
_k = f"{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_resolution_usage = _build_param_out(_res_map)
|
||||||
video_ratio_usage = [VideoParamOut(label=k, count=v) for k, v in sorted(_ratio_map.items(), key=lambda x: -x[1])]
|
video_ratio_usage = _build_param_out(_ratio_map)
|
||||||
video_duration_usage = [VideoParamOut(label=k, count=v) for k, v in sorted(_dur_map.items(), key=lambda x: -x[1])]
|
video_duration_usage = _build_param_out(_dur_map)
|
||||||
|
|
||||||
return AdminStatsOut(
|
return AdminStatsOut(
|
||||||
total_users=total_users,
|
total_users=total_users,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class ModelUsageOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VideoParamOut(BaseModel):
|
class VideoParamOut(BaseModel):
|
||||||
|
model: str
|
||||||
label: str
|
label: str
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user