feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
60
apps/admin/src/utils/bet-labels.ts
Normal file
60
apps/admin/src/utils/bet-labels.ts
Normal 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: '串关' },
|
||||
];
|
||||
290
apps/admin/src/utils/dashboard-charts.ts
Normal file
290
apps/admin/src/utils/dashboard-charts.ts
Normal 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' } }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
43
apps/admin/src/utils/format-amount.ts
Normal file
43
apps/admin/src/utils/format-amount.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user