feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

@@ -0,0 +1,60 @@
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
const STATUS_LABELS: Record<string, string> = {
PENDING: '待结算',
WON: '已赢',
LOST: '已输',
VOID: '作废',
REFUNDED: '已退款',
};
const STATUS_TAG: Record<string, BetTagType> = {
PENDING: 'warning',
WON: 'success',
LOST: 'danger',
VOID: 'info',
REFUNDED: 'info',
};
const TYPE_LABELS: Record<string, string> = {
SINGLE: '单关',
PARLAY: '串关',
};
export function betStatusLabel(status: string) {
return STATUS_LABELS[status] ?? status;
}
export function betStatusTagType(status: string): BetTagType {
return STATUS_TAG[status] ?? 'info';
}
export function betTypeLabel(betType: string) {
return TYPE_LABELS[betType] ?? betType;
}
const SETTLEMENT_LABELS: Record<string, string> = {
PENDING: '待结算',
SETTLED: '已结算',
VOID: '已作废',
};
export function betSettlementLabel(v: string | null | undefined) {
if (!v) return '—';
return SETTLEMENT_LABELS[v] ?? v;
}
export const BET_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'PENDING', label: '待结算' },
{ value: 'WON', label: '已赢' },
{ value: 'LOST', label: '已输' },
{ value: 'VOID', label: '作废' },
{ value: 'REFUNDED', label: '已退款' },
];
export const BET_TYPE_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'SINGLE', label: '单关' },
{ value: 'PARLAY', label: '串关' },
];

View File

@@ -0,0 +1,290 @@
import type { EChartsOption } from 'echarts';
import { formatAmount, formatAmountFull } from './format-amount';
const tooltipBase = {
backgroundColor: '#141414',
borderColor: '#2a2a2a',
textStyle: { color: '#e0e0e0', fontSize: 12 },
};
const axisLabel = { color: '#888', fontSize: 11 };
const splitLine = { lineStyle: { color: '#252525' } };
export type ChartSeries = { name: string; color: string; values: number[] };
export type PieSegment = { label: string; value: number; color: string };
export function buildBarChartOption(
labels: string[],
series: ChartSeries[],
opts?: { amountAxis?: boolean },
): EChartsOption {
const amountAxis = opts?.amountAxis !== false;
return {
backgroundColor: 'transparent',
color: series.map((s) => s.color),
tooltip: {
...tooltipBase,
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v) =>
amountAxis ? formatAmountFull(Number(v)) : fmtCount(Number(v)),
},
legend: {
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#999', fontSize: 11 },
},
grid: { left: 52, right: 12, top: 20, bottom: 48 },
xAxis: {
type: 'category',
data: labels,
axisLabel,
axisLine: { lineStyle: { color: '#333' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: {
...axisLabel,
formatter: (v: number) => (amountAxis ? formatAmount(v) : fmtCount(v)),
},
splitLine,
},
series: series.map((s) => ({
name: s.name,
type: 'bar',
data: s.values,
itemStyle: { color: s.color, borderRadius: [4, 4, 0, 0] },
barMaxWidth: 22,
emphasis: { focus: 'series' },
})),
};
}
export function buildMultiLineChartOption(
labels: string[],
series: ChartSeries[],
): EChartsOption {
return {
backgroundColor: 'transparent',
color: series.map((s) => s.color),
tooltip: {
...tooltipBase,
trigger: 'axis',
valueFormatter: (v) => formatAmountFull(Number(v)),
},
legend: {
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#999', fontSize: 11 },
},
grid: { left: 52, right: 12, top: 20, bottom: 48 },
xAxis: {
type: 'category',
data: labels,
boundaryGap: false,
axisLabel,
axisLine: { lineStyle: { color: '#333' } },
},
yAxis: {
type: 'value',
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
splitLine,
},
series: series.map((s) => ({
name: s.name,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: s.values,
itemStyle: { color: s.color },
lineStyle: { color: s.color, width: 2 },
areaStyle: { color: s.color, opacity: 0.12 },
})),
};
}
export function buildPieChartOption(
title: string,
segments: PieSegment[],
): EChartsOption {
const data = segments.map((s) => ({
name: s.label,
value: s.value,
itemStyle: { color: s.color },
}));
return {
backgroundColor: 'transparent',
tooltip: {
...tooltipBase,
trigger: 'item',
formatter: '{b}{c}{d}%',
},
legend: {
orient: 'vertical',
right: 4,
top: 'middle',
itemWidth: 8,
itemHeight: 8,
textStyle: { color: '#999', fontSize: 11 },
},
series: [
{
name: title,
type: 'pie',
radius: ['42%', '68%'],
center: ['36%', '50%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 4, borderColor: '#111', borderWidth: 2 },
label: {
show: segments.length > 0,
color: '#bbb',
fontSize: 11,
formatter: '{b}\n{d}%',
},
labelLine: { lineStyle: { color: '#444' } },
emphasis: {
label: { fontSize: 12, fontWeight: 'bold' },
scaleSize: 6,
},
data: data.length ? data : [{ name: '暂无数据', value: 1, itemStyle: { color: '#333' } }],
},
],
};
}
function fmtCount(v: number) {
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
/** 7 日金额折线 + 注单柱(双 Y 轴),一图看清趋势 */
export function buildCombinedTrendOption(
labels: string[],
amountSeries: ChartSeries[],
betCounts: number[],
): EChartsOption {
return {
backgroundColor: 'transparent',
color: [...amountSeries.map((s) => s.color), '#fb923c'],
tooltip: {
...tooltipBase,
trigger: 'axis',
formatter(params) {
const items = Array.isArray(params) ? params : [params];
return items
.map((p) => {
const v = Number(p.value ?? 0);
const isCount = p.seriesName === '注单笔数';
const val = isCount ? `${fmtCount(v)}` : formatAmountFull(v);
return `${p.marker ?? ''}${p.seriesName}${val}`;
})
.join('<br/>');
},
},
legend: {
top: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#999', fontSize: 11 },
},
grid: { left: 56, right: 48, top: 36, bottom: 28 },
xAxis: {
type: 'category',
data: labels,
boundaryGap: true,
axisLabel,
axisLine: { lineStyle: { color: '#333' } },
},
yAxis: [
{
type: 'value',
name: '金额',
nameTextStyle: { color: '#666', fontSize: 10 },
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
splitLine,
},
{
type: 'value',
name: '笔数',
nameTextStyle: { color: '#666', fontSize: 10 },
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v) },
splitLine: { show: false },
},
],
series: [
...amountSeries.map((s) => ({
name: s.name,
type: 'line' as const,
yAxisIndex: 0,
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: s.values,
itemStyle: { color: s.color },
lineStyle: { color: s.color, width: 2 },
})),
{
name: '注单笔数',
type: 'bar',
yAxisIndex: 1,
data: betCounts,
barMaxWidth: 14,
itemStyle: { color: 'rgba(251, 146, 60, 0.45)', borderRadius: [3, 3, 0, 0] },
},
],
};
}
/** 三个饼图并排,占一张图 */
export function buildTriplePieOption(
blocks: { title: string; segments: PieSegment[] }[],
): EChartsOption {
const slots = [
{ center: ['18%', '58%'] as [string, string], titleLeft: '14%' },
{ center: ['50%', '58%'] as [string, string], titleLeft: '46%' },
{ center: ['82%', '58%'] as [string, string], titleLeft: '78%' },
];
return {
backgroundColor: 'transparent',
tooltip: {
...tooltipBase,
trigger: 'item',
formatter: '{b}{c}{d}%',
},
graphic: blocks.map((b, i) => ({
type: 'text' as const,
left: slots[i]?.titleLeft ?? '50%',
top: '6%',
style: {
text: b.title,
fill: '#aaa',
fontSize: 12,
fontWeight: 600,
textAlign: 'center',
},
})),
series: blocks.map((b, i) => {
const data = b.segments.map((s) => ({
name: s.label,
value: s.value,
itemStyle: { color: s.color },
}));
return {
name: b.title,
type: 'pie' as const,
radius: ['32%', '48%'],
center: slots[i]?.center ?? ['50%', '55%'],
label: { show: false },
labelLine: { show: false },
data: data.length
? data
: [{ name: '暂无', value: 1, itemStyle: { color: '#333' } }],
};
}),
};
}

View File

@@ -0,0 +1,43 @@
/** 完整数字(悬停提示、详情对照) */
export function formatAmountFull(value: string | number | null | undefined): string {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toLocaleString('zh-CN', { maximumFractionDigits: 4 });
}
function unitPart(abs: number, divisor: number, maxDecimals: number): string {
return (abs / divisor).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxDecimals,
});
}
/**
* 金额展示≥1万用「万」≥1亿用「亿」避免表格撑破布局
*/
export function formatAmount(
value: string | number | null | undefined,
maxDecimals = 2,
): string {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
const sign = n < 0 ? '-' : '';
const abs = Math.abs(n);
if (abs >= 1e8) {
return `${sign}${unitPart(abs, 1e8, maxDecimals)}亿`;
}
if (abs >= 1e4) {
return `${sign}${unitPart(abs, 1e4, maxDecimals)}`;
}
return n.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxDecimals,
});
}
export function shouldCompactAmount(value: string | number | null | undefined): boolean {
return Math.abs(Number(value) || 0) >= 1e4;
}