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

@@ -1,5 +1,7 @@
import {
BadRequestException,
Controller,
Delete,
Get,
Post,
Put,
@@ -24,7 +26,18 @@ import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
import { AdminDashboardService } from './admin-dashboard.service';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
IsBoolean,
MinLength,
IsIn,
Min,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
class CreateUserDto {
@IsString()
@@ -43,6 +56,116 @@ class CreateUserDto {
creditLimit?: number;
}
class CreatePlayerAdminDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
initialDeposit?: number;
@IsOptional()
@IsString()
remark?: string;
}
class UpdatePlayerAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
/** 传空字符串表示改为平台直属(无代理) */
@IsOptional()
@IsString()
parentId?: string;
}
class CreateAgentAdminDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsNumber()
@Min(0)
creditLimit!: number;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
}
class UpdateAgentAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
}
class DepositDto {
@IsNumber()
amount!: number;
@@ -55,15 +178,24 @@ class DepositDto {
remark?: string;
}
class CreateMatchDto {
class CreatePlatformMatchDto {
@IsString()
leagueId!: string;
leagueEn!: string;
@IsString()
homeTeamId!: string;
leagueZh!: string;
@IsString()
awayTeamId!: string;
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsString()
startTime!: string;
@@ -71,6 +203,15 @@ class CreateMatchDto {
@IsOptional()
@IsBoolean()
isHot?: boolean;
@IsOptional()
@IsNumber()
displayOrder?: number;
}
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
if (!body || typeof body !== 'object') return false;
return Array.isArray((body as ZhiboMatchesBundleExport).matches);
}
class ScoreDto {
@@ -123,44 +264,73 @@ export class AdminController {
private audit: AuditService,
private bets: BetsService,
private prisma: PrismaService,
private readonly dashboardService: AdminDashboardService,
) {}
@Get('dashboard')
async dashboard() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
]);
return jsonResponse({
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayPayout: todayBets._sum.actualReturn,
pendingSettlement: pendingMatches,
totalPlayers,
});
async getDashboard() {
const overview = await this.dashboardService.getOverview();
return jsonResponse(overview);
}
@Get('users')
async listUsers(@Query('page') page?: string) {
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
async listUsers(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('parentId') parentId?: string,
@Query('status') status?: string,
) {
const result = await this.users.listPlayers(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
{
keyword,
parentId: parentId ? BigInt(parentId) : undefined,
status,
},
);
return jsonResponse(result);
}
@Get('users/:id')
async getUserDetail(@Param('id') id: string) {
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('users/:id')
async updateUser(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdatePlayerAdminDto,
) {
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('users')
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
async createPlayer(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerAdminDto,
) {
const user = await this.agents.createPlayer(operatorId, {
username: dto.username,
password: dto.password,
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
parentId: dto.parentId ? BigInt(dto.parentId) : undefined,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
initialDeposit: dto.initialDeposit,
depositRemark: dto.remark,
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
});
await this.audit.log({
operatorId,
@@ -169,24 +339,73 @@ export class AdminController {
module: 'USERS',
targetId: user.id.toString(),
});
return jsonResponse(user);
const detail = await this.users.getPlayerAdminDetail(user.id);
return jsonResponse(detail);
}
@Get('agents/options')
async listAgentOptions() {
const agents = await this.prisma.user.findMany({
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
select: { id: true, username: true },
orderBy: { username: 'asc' },
});
return jsonResponse(
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
);
}
@Get('agents')
async listAgents() {
const agents = await this.prisma.agentProfile.findMany({
include: { user: true },
async listAgents(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
});
return jsonResponse(agents);
return jsonResponse(result);
}
@Get('agents/:id')
async getAgentDetail(@Param('id') id: string) {
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('agents/:id')
async updateAgent(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateAgentAdminDto,
) {
const detail = await this.agents.updateAgentAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT',
module: 'AGENTS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('agents')
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
async createAgent(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateAgentAdminDto,
) {
const user = await this.agents.createAgent(operatorId, {
username: dto.username,
password: dto.password,
level: 1,
creditLimit: dto.creditLimit,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
cashbackRate: dto.cashbackRate,
});
await this.audit.log({
operatorId,
@@ -195,7 +414,8 @@ export class AdminController {
module: 'AGENTS',
targetId: user.id.toString(),
});
return jsonResponse(user);
const detail = await this.agents.getAgentAdminDetail(user.id);
return jsonResponse(detail);
}
@Post('agents/:id/credit')
@@ -257,27 +477,100 @@ export class AdminController {
}
@Get('matches')
async listMatches() {
const matches = await this.prisma.match.findMany({
include: { markets: { include: { selections: true } } },
orderBy: { startTime: 'desc' },
async listMatches(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const skip = (p - 1) * size;
const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null };
if (status) where.status = status;
const kw = keyword?.trim();
if (kw) {
where.OR = [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const [items, total] = await Promise.all([
this.prisma.match.findMany({
where,
include: {
homeTeam: true,
awayTeam: true,
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip,
take: size,
}),
this.prisma.match.count({ where }),
]);
return jsonResponse({ items, total, page: p, pageSize: size });
}
@Get('matches/:id')
async getMatch(@Param('id') id: string) {
const match = await this.matches.getAdminMatchDetail(BigInt(id));
return jsonResponse(match);
}
@Put('matches/:id')
async updateMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: CreatePlatformMatchDto,
) {
const match = await this.matches.updatePlatformMatch(BigInt(id), {
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
updatedBy: operatorId,
});
return jsonResponse(matches);
return jsonResponse(match);
}
@Delete('matches/:id')
async deleteMatch(@Param('id') id: string) {
await this.matches.deleteMatch(BigInt(id));
return jsonResponse({ deleted: true });
}
@Post('matches')
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
const match = await this.matches.createMatch({
leagueId: BigInt(dto.leagueId),
homeTeamId: BigInt(dto.homeTeamId),
awayTeamId: BigInt(dto.awayTeamId),
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
const match = await this.matches.createPlatformMatch({
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
createdBy: operatorId,
});
return jsonResponse(match);
}
@Post('matches/import')
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
if (!isZhiboBundlePayload(dto)) {
throw new BadRequestException('Invalid import payload: matches[] required');
}
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
return jsonResponse(result);
}
@Post('matches/:id/publish')
async publishMatch(@Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
@@ -343,20 +636,31 @@ export class AdminController {
}
@Get('bets')
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const where = status ? { status } : {};
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where }),
]);
return jsonResponse({ items, total });
async listBets(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('betType') betType?: string,
@Query('placedFrom') placedFrom?: string,
@Query('placedTo') placedTo?: string,
) {
const result = await this.bets.listBetsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status: status || undefined,
betType: betType || undefined,
placedFrom,
placedTo,
});
return jsonResponse(result);
}
@Get('bets/:id')
async getBet(@Param('id') id: string) {
const detail = await this.bets.getBetAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Post('cashbacks/preview')
@@ -393,8 +697,16 @@ export class AdminController {
}
@Get('audit-logs')
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
async auditLogs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('module') module?: string,
) {
const result = await this.audit.list(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
module || undefined,
);
return jsonResponse(result);
}
}