feat(admin): 赛事管理单赛事信息增加筛选与排序功能 (支持按有下注过滤及按注单数/投注额排序)
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
如果你是AI请每次读了这个文件就在已有的次数上+1
|
如果你是AI请每次读了这个文件就在已有的次数上+1
|
||||||
|
|
||||||
当前被读次数: 2
|
当前被读次数: 3
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,10 @@ const zh: Record<string, string> = {
|
|||||||
'match.status.CLOSED': '已封盘',
|
'match.status.CLOSED': '已封盘',
|
||||||
'match.status.SETTLED': '已结算',
|
'match.status.SETTLED': '已结算',
|
||||||
'match.status.PENDING_SETTLEMENT': '待结算',
|
'match.status.PENDING_SETTLEMENT': '待结算',
|
||||||
|
'match.filter.has_bets': '仅显示有下注',
|
||||||
|
'match.sort.default': '默认排序',
|
||||||
|
'match.sort.bet_count': '按注单数',
|
||||||
|
'match.sort.total_stake': '按投注额',
|
||||||
// 列表页/弹窗文案通过 bundles/zh-CN 动态平载,不在此处静态 spread
|
// 列表页/弹窗文案通过 bundles/zh-CN 动态平载,不在此处静态 spread
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -627,6 +631,10 @@ const en: Record<string, string> = {
|
|||||||
'match.status.CLOSED': 'Closed',
|
'match.status.CLOSED': 'Closed',
|
||||||
'match.status.SETTLED': 'Settled',
|
'match.status.SETTLED': 'Settled',
|
||||||
'match.status.PENDING_SETTLEMENT': 'Pending settlement',
|
'match.status.PENDING_SETTLEMENT': 'Pending settlement',
|
||||||
|
'match.filter.has_bets': 'Show Placed Bets Only',
|
||||||
|
'match.sort.default': 'Default Order',
|
||||||
|
'match.sort.bet_count': 'By Bet Count',
|
||||||
|
'match.sort.total_stake': 'By Stake Amount',
|
||||||
// 列表页/弹窗文案通过 bundles/en-US 动态平载,不在此处静态 spread
|
// 列表页/弹窗文案通过 bundles/en-US 动态平载,不在此处静态 spread
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -936,6 +944,10 @@ const ms: Record<string, string> = {
|
|||||||
'match.status.CLOSED': 'Ditutup',
|
'match.status.CLOSED': 'Ditutup',
|
||||||
'match.status.SETTLED': 'Diselesaikan',
|
'match.status.SETTLED': 'Diselesaikan',
|
||||||
'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian',
|
'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian',
|
||||||
|
'match.filter.has_bets': 'Tunjukkan Pertaruhan Sahaja',
|
||||||
|
'match.sort.default': 'Susunan Lalai',
|
||||||
|
'match.sort.bet_count': 'Ikut Bil. Pertaruhan',
|
||||||
|
'match.sort.total_stake': 'Ikut Jumlah Taruhan',
|
||||||
// 列表页/弹窗文案通过 bundles/ms-MY 动态平载,不在此处静态 spread
|
// 列表页/弹窗文案通过 bundles/ms-MY 动态平载,不在此处静态 spread
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const router = useRouter();
|
|||||||
const archiveVisible = ref(false);
|
const archiveVisible = ref(false);
|
||||||
const archiveMatchId = ref('');
|
const archiveMatchId = ref('');
|
||||||
const archiveTitle = ref('');
|
const archiveTitle = ref('');
|
||||||
|
const filterHasBets = ref(false);
|
||||||
|
const orderBy = ref('default');
|
||||||
|
|
||||||
|
function onFilterChange() {
|
||||||
|
matchPage.value = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
const matches = ref<unknown[]>([]);
|
const matches = ref<unknown[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const matchPage = ref(1);
|
const matchPage = ref(1);
|
||||||
@@ -44,6 +52,8 @@ async function load() {
|
|||||||
locale: locale.value,
|
locale: locale.value,
|
||||||
page: matchPage.value,
|
page: matchPage.value,
|
||||||
pageSize: matchPageSize.value,
|
pageSize: matchPageSize.value,
|
||||||
|
hasBets: filterHasBets.value ? 'true' : undefined,
|
||||||
|
orderBy: orderBy.value !== 'default' ? orderBy.value : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const payload = data.data as {
|
const payload = data.data as {
|
||||||
@@ -328,6 +338,16 @@ defineExpose({ reload: load });
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="league-matches-panel">
|
<div class="league-matches-panel">
|
||||||
|
<div class="nested-panel-toolbar">
|
||||||
|
<el-checkbox v-model="filterHasBets" size="default" @change="onFilterChange">
|
||||||
|
{{ t('match.filter.has_bets') }}
|
||||||
|
</el-checkbox>
|
||||||
|
<el-select v-model="orderBy" size="small" style="width: 140px;" @change="onFilterChange">
|
||||||
|
<el-option :label="t('match.sort.default')" value="default" />
|
||||||
|
<el-option :label="t('match.sort.bet_count')" value="betCount" />
|
||||||
|
<el-option :label="t('match.sort.total_stake')" value="totalStake" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||||
<el-table-column type="index" :index="(i: number) => (matchPage - 1) * matchPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
<el-table-column type="index" :index="(i: number) => (matchPage - 1) * matchPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
@@ -476,9 +496,14 @@ defineExpose({ reload: load });
|
|||||||
}
|
}
|
||||||
.nested-panel-toolbar {
|
.nested-panel-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: flex-start;
|
||||||
margin-bottom: 8px;
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border-soft, #eaeaea);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.nested-pager {
|
.nested-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1995,6 +1995,8 @@ export class AdminController {
|
|||||||
@Query('locale') locale?: string,
|
@Query('locale') locale?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('hasBets') hasBets?: string,
|
||||||
|
@Query('orderBy') orderBy?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
|
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
@@ -2002,6 +2004,8 @@ export class AdminController {
|
|||||||
locale: locale || undefined,
|
locale: locale || undefined,
|
||||||
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
||||||
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
|
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
|
||||||
|
hasBets: hasBets || undefined,
|
||||||
|
orderBy: orderBy || undefined,
|
||||||
});
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,3 +301,89 @@ describe('MatchesService listUpcomingPublished', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MatchesService listAdminLeagueMatches', () => {
|
||||||
|
const leagueId = BigInt(1);
|
||||||
|
|
||||||
|
let prisma: {
|
||||||
|
match: { findMany: jest.Mock; count: jest.Mock };
|
||||||
|
entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||||
|
let service: MatchesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
match: { findMany: jest.fn(), count: jest.fn() },
|
||||||
|
entityTranslation: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
matchBetStats = { betStatsForMatches: jest.fn() };
|
||||||
|
service = new MatchesService(
|
||||||
|
prisma as never,
|
||||||
|
{ syncWithLeaguePublished: jest.fn() } as never,
|
||||||
|
matchBetStats as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by hasBets = true', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
|
||||||
|
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
|
||||||
|
];
|
||||||
|
prisma.match.findMany.mockResolvedValue(mockMatches);
|
||||||
|
const statsMap = new Map();
|
||||||
|
statsMap.set('10', { betCount: 5, totalStake: '500.00', pendingCount: 0 });
|
||||||
|
statsMap.set('11', { betCount: 0, totalStake: '0.00', pendingCount: 0 });
|
||||||
|
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
|
||||||
|
|
||||||
|
const result = await service.listAdminLeagueMatches(leagueId, {
|
||||||
|
hasBets: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0].id).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by betCount', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
|
||||||
|
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
|
||||||
|
];
|
||||||
|
prisma.match.findMany.mockResolvedValue(mockMatches);
|
||||||
|
const statsMap = new Map();
|
||||||
|
statsMap.set('10', { betCount: 5, totalStake: '500.00', pendingCount: 0 });
|
||||||
|
statsMap.set('11', { betCount: 15, totalStake: '1500.00', pendingCount: 0 });
|
||||||
|
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
|
||||||
|
|
||||||
|
const result = await service.listAdminLeagueMatches(leagueId, {
|
||||||
|
orderBy: 'betCount',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items[0].id).toBe('11');
|
||||||
|
expect(result.items[1].id).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by totalStake', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
|
||||||
|
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
|
||||||
|
];
|
||||||
|
prisma.match.findMany.mockResolvedValue(mockMatches);
|
||||||
|
const statsMap = new Map();
|
||||||
|
statsMap.set('10', { betCount: 5, totalStake: '1200.00', pendingCount: 0 });
|
||||||
|
statsMap.set('11', { betCount: 15, totalStake: '800.00', pendingCount: 0 });
|
||||||
|
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
|
||||||
|
|
||||||
|
const result = await service.listAdminLeagueMatches(leagueId, {
|
||||||
|
orderBy: 'totalStake',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items[0].id).toBe('10');
|
||||||
|
expect(result.items[1].id).toBe('11');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -556,6 +556,8 @@ export class MatchesService {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
hasBets?: string;
|
||||||
|
orderBy?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const where: Prisma.MatchWhereInput = {
|
const where: Prisma.MatchWhereInput = {
|
||||||
@@ -574,6 +576,70 @@ export class MatchesService {
|
|||||||
}
|
}
|
||||||
const page = Math.max(1, opts.page ?? 1);
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
|
||||||
|
|
||||||
|
if (opts.hasBets === 'true' || opts.orderBy === 'betCount' || opts.orderBy === 'totalStake') {
|
||||||
|
const allRows = await this.prisma.match.findMany({
|
||||||
|
where,
|
||||||
|
include: { homeTeam: true, awayTeam: true },
|
||||||
|
});
|
||||||
|
const locale = opts.locale ?? 'zh-CN';
|
||||||
|
const betStatsMap = await this.betStatsForMatches(allRows.map((m) => m.id));
|
||||||
|
const itemsWithStats = await Promise.all(
|
||||||
|
allRows.map(async (m) => {
|
||||||
|
const [homeTeamName, awayTeamName] = await Promise.all([
|
||||||
|
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||||
|
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||||
|
]);
|
||||||
|
const raw = betStatsMap.get(m.id.toString());
|
||||||
|
const betCount = raw?.betCount ?? 0;
|
||||||
|
const totalStake = raw?.totalStake ?? '0';
|
||||||
|
const pendingBets = raw?.pendingCount ?? 0;
|
||||||
|
return {
|
||||||
|
id: m.id.toString(),
|
||||||
|
status: m.status,
|
||||||
|
isOutright: m.isOutright,
|
||||||
|
isHot: m.isHot,
|
||||||
|
displayOrder: m.displayOrder,
|
||||||
|
startTime: m.startTime,
|
||||||
|
matchName: m.matchName,
|
||||||
|
homeTeamName,
|
||||||
|
awayTeamName,
|
||||||
|
homeTeam: { code: m.homeTeam.code },
|
||||||
|
awayTeam: { code: m.awayTeam.code },
|
||||||
|
betCount,
|
||||||
|
totalStake,
|
||||||
|
pendingBets,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let filteredItems = itemsWithStats;
|
||||||
|
if (opts.hasBets === 'true') {
|
||||||
|
filteredItems = itemsWithStats.filter((item) => item.betCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.orderBy === 'betCount') {
|
||||||
|
filteredItems.sort((a, b) => b.betCount - a.betCount);
|
||||||
|
} else if (opts.orderBy === 'totalStake') {
|
||||||
|
filteredItems.sort((a, b) => {
|
||||||
|
const stakeA = parseFloat(a.totalStake);
|
||||||
|
const stakeB = parseFloat(b.totalStake);
|
||||||
|
return stakeB - stakeA;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filteredItems.sort((a, b) => {
|
||||||
|
if (a.displayOrder !== b.displayOrder) {
|
||||||
|
return a.displayOrder - b.displayOrder;
|
||||||
|
}
|
||||||
|
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = filteredItems.length;
|
||||||
|
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
return { items: paginatedItems, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
const [total, rows] = await Promise.all([
|
const [total, rows] = await Promise.all([
|
||||||
this.prisma.match.count({ where }),
|
this.prisma.match.count({ where }),
|
||||||
this.prisma.match.findMany({
|
this.prisma.match.findMany({
|
||||||
|
|||||||
Reference in New Issue
Block a user