Files
thebet365/apps/admin/src/views/matches/LeagueMatchesPanel.vue
Mars a3e8958406 sync(theme-4): 从 main 同步优胜赛结算态与钱包流水展示优化
- checkout shared/api/admin 自 d3c2114
- player: outright 结算态逻辑、wallet txAmountClass、i18n 新增 key(保留海军蓝主题样式)
2026-06-23 13:53:20 +08:00

865 lines
24 KiB
Vue

<script setup lang="ts">
import { ref, watch, h, defineAsyncComponent, onActivated } from 'vue';
import { consumeAdminListStale } from '../../utils/adminListStale';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
import { formatAmount } from '../../utils/format-amount';
import {
formatPlatformMatchDateTime,
platformPickerDateTimeToIso,
} from '@thebet365/shared';
const MatchEventEditor = defineAsyncComponent(
() => import('./MatchEventEditor.vue'),
);
const MatchMarketsPanel = defineAsyncComponent(
() => import('./MatchMarketsPanel.vue'),
);
const props = withDefaults(
defineProps<{
leagueId: string;
filterStatus?: string;
keyword?: string;
}>(),
{
filterStatus: '',
keyword: '',
},
);
const emit = defineEmits<{
changed: [];
'add-match': [];
'league-meta': [meta: { isOutrightSettled: boolean }];
}>();
const { t, locale } = useAdminLocale();
const router = useRouter();
const archiveVisible = ref(false);
const archiveMatchId = ref('');
const archiveTitle = ref('');
const manageDialogVisible = ref(false);
const marketsDialogVisible = ref(false);
const dialogMatchId = ref('');
const dialogMatchTitle = ref('');
const filterHasBets = ref(false);
const orderBy = ref('default');
const localKeyword = ref('');
const localStatus = ref('');
const kickoffRange = ref<[string, string] | null>(null);
const matches = ref<unknown[]>([]);
const loading = ref(false);
const matchPage = ref(1);
const matchPageSize = ref(10);
const matchTotal = ref(0);
let loadTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.keyword,
(value) => {
localKeyword.value = value ?? '';
scheduleLoad(true);
},
{ immediate: true },
);
watch(
() => props.filterStatus,
(value) => {
localStatus.value = value ?? '';
scheduleLoad(true);
},
{ immediate: true },
);
watch(
() => props.leagueId,
() => scheduleLoad(true),
{ immediate: true },
);
function onSearch() {
matchPage.value = 1;
void load();
}
function onFilterChange() {
matchPage.value = 1;
void load();
}
function resetFilters() {
localKeyword.value = '';
localStatus.value = '';
kickoffRange.value = null;
filterHasBets.value = false;
orderBy.value = 'default';
onFilterChange();
}
async function load(options: { silent?: boolean } = {}) {
if (!options.silent) loading.value = true;
try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
params: {
status: localStatus.value || undefined,
keyword: localKeyword.value.trim() || undefined,
locale: locale.value,
page: matchPage.value,
pageSize: matchPageSize.value,
hasBets: filterHasBets.value ? 'true' : undefined,
orderBy: orderBy.value !== 'default' ? orderBy.value : undefined,
startFrom: kickoffRange.value?.[0]
? platformPickerDateTimeToIso(kickoffRange.value[0])
: undefined,
startTo: kickoffRange.value?.[1]
? platformPickerDateTimeToIso(kickoffRange.value[1])
: undefined,
},
});
const payload = data.data as {
items: unknown[];
total: number;
page: number;
pageSize: number;
league?: { isOutrightSettled?: boolean };
};
matches.value = payload.items;
matchTotal.value = payload.total;
emit('league-meta', {
isOutrightSettled: Boolean(payload.league?.isOutrightSettled),
});
matchPage.value = payload.page;
matchPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
} finally {
if (!options.silent) loading.value = false;
}
}
onActivated(() => {
if (!props.leagueId) return;
const stale = consumeAdminListStale();
if (stale || matches.value.length > 0) {
void load({ silent: !stale && matches.value.length > 0 });
}
});
function scheduleLoad(resetPage = false) {
if (!props.leagueId) return;
if (resetPage) matchPage.value = 1;
if (loadTimer) clearTimeout(loadTimer);
loadTimer = setTimeout(() => {
loadTimer = null;
void load();
}, 200);
}
function onMatchPageChange(page: number) {
matchPage.value = page;
void load();
}
function onMatchPageSizeChange(size: number) {
matchPageSize.value = size;
matchPage.value = 1;
void load();
}
function notifyParent() {
emit('changed');
load();
}
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
marketTypes: [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
'FT_ODD_EVEN',
'HT_1X2',
'HT_HANDICAP',
'HT_OVER_UNDER',
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
],
});
ElMessage.success(t('msg.published'));
notifyParent();
}
async function unpublish(id: string) {
try {
await ElMessageBox.confirm(t('match.confirm_unpublish'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('match.btn.unpublish'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
await api.post(`/admin/matches/${id}/unpublish`);
ElMessage.success(t('msg.match_unpublished'));
notifyParent();
}
async function close(id: string) {
await api.post(`/admin/matches/${id}/close`);
ElMessage.success(t('msg.closed'));
notifyParent();
}
function openManage(id: string, title?: string) {
dialogMatchId.value = id;
dialogMatchTitle.value = title?.trim() || `#${id}`;
manageDialogVisible.value = true;
}
function openMarkets(id: string, title?: string) {
dialogMatchId.value = id;
dialogMatchTitle.value = title?.trim() || `#${id}`;
marketsDialogVisible.value = true;
}
function onManageSaved() {
manageDialogVisible.value = false;
notifyParent();
}
function settle(id: string) {
void router.push({
path: `/settlement/${id}`,
query: { returnTo: `/matches/leagues/${props.leagueId}` },
});
}
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
function matchStatusText(status: string) {
const key = `match.status.${status}`;
const v = t(key);
return v !== key ? v : status;
}
const statusTagTypes: Record<string, TagType> = {
DRAFT: 'info',
PUBLISHED: 'warning',
CLOSED: 'danger',
SETTLED: 'success',
};
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function matchStatus(row: unknown) {
return String(rowOf(row).status ?? '');
}
function matchStatusLabel(row: unknown) {
return matchStatusText(matchStatus(row));
}
function matchStatusType(row: unknown): TagType {
return statusTagTypes[matchStatus(row)] ?? 'info';
}
function matchId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function matchTime(row: unknown) {
return formatPlatformMatchDateTime(String(rowOf(row).startTime), locale.value);
}
function betCount(row: unknown) {
return Number(rowOf(row).betCount ?? 0);
}
function totalStake(row: unknown) {
return formatAmount(String(rowOf(row).totalStake ?? '0'));
}
function pendingBets(row: unknown) {
return Number(rowOf(row).pendingBets ?? 0);
}
function matchTitle(row: unknown) {
const r = rowOf(row);
const home =
String(r.homeTeamName ?? '').trim() ||
(r.homeTeam as { code?: string })?.code ||
'';
const away =
String(r.awayTeamName ?? '').trim() ||
(r.awayTeam as { code?: string })?.code ||
'';
if (home && away) return `${home} vs ${away}`;
const matchName = String(r.matchName ?? '').trim();
return matchName || '—';
}
function canManage(row: unknown) {
const s = matchStatus(row);
return s === 'DRAFT' || s === 'PUBLISHED';
}
function canDeleteRow(_row: unknown) {
return true;
}
function openArchive(row: unknown) {
archiveMatchId.value = matchId(row);
archiveTitle.value = matchTitle(row);
archiveVisible.value = true;
}
function onMatchArchived() {
notifyParent();
}
function canPublishRow(row: unknown) {
return matchStatus(row) === 'DRAFT';
}
function canUnpublishRow(row: unknown) {
const s = matchStatus(row);
return s === 'PUBLISHED' || s === 'CLOSED' || s === 'PENDING_SETTLEMENT';
}
function canCloseRow(row: unknown) {
return matchStatus(row) === 'PUBLISHED';
}
function canReopenRow(row: unknown) {
const s = matchStatus(row);
return s === 'CLOSED' || s === 'PENDING_SETTLEMENT';
}
function canSettleRow(row: unknown) {
const s = matchStatus(row);
return s === 'CLOSED' || s === 'PENDING_SETTLEMENT' || s === 'SETTLED';
}
function settleButtonLabel(row: unknown) {
return matchStatus(row) === 'SETTLED' ? t('common.resettle') : t('common.settle');
}
function kickoffPassed(row: unknown) {
return new Date(String(rowOf(row).startTime)) <= new Date();
}
async function promptReopenKickoff(): Promise<string | null> {
const kickoff = ref('');
try {
await ElMessageBox({
title: t('match.reopen_kickoff_title'),
message: () =>
h('div', { class: 'reopen-kickoff-prompt' }, [
h(
'p',
{
style: 'margin: 0 0 12px; font-size: 13px; color: var(--el-text-color-secondary)',
},
t('match.reopen_kickoff_hint'),
),
h(
'p',
{
style: 'margin: 0 0 12px; font-size: 12px; color: var(--el-text-color-secondary)',
},
t('match.timezone.platform_hint'),
),
h(ElDatePicker, {
modelValue: kickoff.value,
'onUpdate:modelValue': (v: string) => {
kickoff.value = v;
},
type: 'datetime',
valueFormat: 'YYYY-MM-DDTHH:mm:ss',
style: 'width: 100%',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
beforeClose: (action, _instance, done) => {
if (action === 'confirm') {
const iso = kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : '';
if (!iso || Number.isNaN(new Date(iso).getTime()) || new Date(iso) <= new Date()) {
ElMessage.warning(t('match.reopen_kickoff_invalid'));
return;
}
}
done();
},
});
return kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : null;
} catch {
return null;
}
}
async function reopenRow(row: unknown) {
const id = matchId(row);
let startTime: string | undefined;
if (kickoffPassed(row)) {
const picked = await promptReopenKickoff();
if (!picked) return;
startTime = picked;
}
try {
await api.post(`/admin/matches/${id}/reopen`, startTime ? { startTime } : {});
ElMessage.success(t('msg.reopened'));
notifyParent();
} catch (e) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
}
}
async function confirmDelete(row: unknown) {
openArchive(row);
}
defineExpose({ reload: load });
</script>
<template>
<div class="league-matches-panel">
<div class="nested-panel-toolbar">
<div class="nested-panel-toolbar__filters">
<div class="nested-panel-toolbar__field">
<span class="nested-panel-toolbar__label">{{ t('common.keyword') }}</span>
<el-input
v-model="localKeyword"
:placeholder="t('match.filter.keyword_ph')"
clearable
size="small"
style="width: 168px"
@keyup.enter="onSearch"
/>
</div>
<div class="nested-panel-toolbar__field">
<span class="nested-panel-toolbar__label">{{ t('common.status') }}</span>
<el-select
v-model="localStatus"
:placeholder="t('common.all')"
clearable
size="small"
style="width: 120px"
@change="onFilterChange"
>
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
<el-option :label="t('match.status.PENDING_SETTLEMENT')" value="PENDING_SETTLEMENT" />
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
</el-select>
</div>
<div class="nested-panel-toolbar__field nested-panel-toolbar__field--range">
<span class="nested-panel-toolbar__label">{{ t('match.field.kickoff') }}</span>
<el-date-picker
v-model="kickoffRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
:start-placeholder="t('match.filter.kickoff_from')"
:end-placeholder="t('match.filter.kickoff_to')"
size="small"
clearable
style="width: 320px"
@change="onFilterChange"
/>
</div>
<el-button type="primary" size="small" @click="onSearch">
{{ t('common.search') }}
</el-button>
<el-button size="small" @click="resetFilters">
{{ t('common.reset') }}
</el-button>
</div>
<div class="nested-panel-toolbar__extras">
<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: 148px;" @change="onFilterChange">
<el-option :label="t('match.sort.default')" value="default" />
<el-option :label="t('match.sort.kickoff_asc')" value="kickoffAsc" />
<el-option :label="t('match.sort.kickoff_desc')" value="kickoffDesc" />
<el-option :label="t('match.sort.bet_count')" value="betCount" />
<el-option :label="t('match.sort.total_stake')" value="totalStake" />
</el-select>
</div>
</div>
<div class="nested-table-wrap">
<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 :label="t('match.col.matchup')" min-width="180">
<template #default="{ row }">
<span class="matchup-link">{{ matchTitle(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.kickoff')" min-width="190">
<template #default="{ row }">{{ matchTime(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
<template #default="{ row }">
<span :class="{ 'bet-stat-active': betCount(row) > 0 }">{{ betCount(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.total_stake')" width="100" align="right">
<template #default="{ row }">{{ totalStake(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="pendingBets(row) > 0" type="warning" size="small" effect="plain">
{{ pendingBets(row) }}
</el-tag>
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column min-width="360" align="center">
<template #header>
<div class="actions-col-header">
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
</div>
</template>
<template #default="{ row }">
<div class="action-btns">
<div class="action-row action-row--primary">
<el-button
size="small"
type="primary"
:disabled="!canManage(row)"
@click.stop="openManage(matchId(row), matchTitle(row))"
>
{{ t('matchEditor.manage_btn') }}
</el-button>
<el-button
size="small"
type="primary"
plain
class="action-btn--markets"
:disabled="!canManage(row)"
@click.stop="openMarkets(matchId(row), matchTitle(row))"
>
{{ t('match.btn.markets') }}
</el-button>
<el-button
v-if="canPublishRow(row)"
size="small"
type="success"
@click="publish(matchId(row))"
>
{{ t('common.publish') }}
</el-button>
<el-button
v-else-if="canUnpublishRow(row)"
size="small"
type="warning"
@click="unpublish(matchId(row))"
>
{{ t('match.btn.unpublish') }}
</el-button>
</div>
<div class="action-row action-row--workflow">
<el-button
size="small"
type="warning"
:disabled="!canCloseRow(row)"
@click="close(matchId(row))"
>
{{ t('common.close_betting') }}
</el-button>
<el-button
size="small"
type="success"
plain
:disabled="!canReopenRow(row)"
@click="reopenRow(row)"
>
{{ t('common.reopen_betting') }}
</el-button>
<el-button
size="small"
type="primary"
:disabled="!canSettleRow(row)"
@click="settle(matchId(row))"
>
{{ settleButtonLabel(row) }}
</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmDelete(row)"
>
{{ t('common.delete') }}
</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
</div>
<div v-if="matchTotal > matchPageSize" class="nested-pager">
<el-pagination
v-model:current-page="matchPage"
v-model:page-size="matchPageSize"
:total="matchTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
small
@current-change="onMatchPageChange"
@size-change="onMatchPageSizeChange"
/>
</div>
<MatchArchiveDialog
v-model="archiveVisible"
:match-id="archiveMatchId"
:title="archiveTitle"
@archived="onMatchArchived"
/>
<el-dialog
v-model="manageDialogVisible"
:title="`${t('matchEditor.title')} · ${dialogMatchTitle}`"
width="920px"
top="4vh"
destroy-on-close
append-to-body
class="match-manage-dialog"
>
<MatchEventEditor
v-if="manageDialogVisible && dialogMatchId"
:match-id-prop="dialogMatchId"
embedded
@saved="onManageSaved"
/>
</el-dialog>
<el-dialog
v-model="marketsDialogVisible"
:title="`${t('matchEditor.section_markets')} · ${dialogMatchTitle}`"
width="min(1200px, 96vw)"
top="3vh"
destroy-on-close
append-to-body
class="match-markets-dialog"
>
<div v-if="marketsDialogVisible && dialogMatchId" class="match-markets-dialog__body">
<MatchMarketsPanel :match-id="dialogMatchId" />
</div>
</el-dialog>
</div>
</template>
<style scoped>
.league-matches-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
width: min(100%, calc(100vw - 272px));
max-width: calc(100vw - 272px);
padding: 14px 14px 14px;
overflow: hidden;
background: #fbfaf7;
}
.nested-panel-toolbar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 16px;
margin: 2px 0 12px;
padding: 10px 14px;
background: #ffffff;
border: 1px solid var(--border-soft, #eaeaea);
border-radius: 6px;
}
.nested-panel-toolbar__filters,
.nested-panel-toolbar__extras {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.nested-panel-toolbar__field {
display: inline-flex;
align-items: center;
gap: 6px;
}
.nested-panel-toolbar__field--range {
min-width: 0;
}
.nested-panel-toolbar__label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.nested-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--border-soft, #eaeaea);
border-radius: 6px;
background: #ffffff;
}
.nested-table-wrap :deep(.el-table__header-wrapper) {
position: sticky;
top: 0;
z-index: 2;
background: var(--el-table-header-bg-color, #fafafa);
}
.nested-pager {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding-top: 8px;
background: #fbfaf7;
}
.actions-col-header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 4px;
box-sizing: border-box;
white-space: nowrap;
}
.actions-col-header__label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
}
.actions-col-header :deep(.el-button) {
margin: 0 !important;
padding: 6px 10px !important;
height: 28px !important;
min-height: 28px !important;
font-size: 12px !important;
font-weight: 600;
border-radius: 6px;
white-space: nowrap;
}
.nested-match-table :deep(.el-table__header .el-table__cell) {
padding: 6px 0;
}
.nested-match-table :deep(.el-table__header .cell),
.nested-match-table :deep(.el-table__body .cell) {
overflow: visible;
}
.nested-match-table :deep(.el-table__body td.el-table__cell) {
padding-top: 8px;
padding-bottom: 8px;
}
.empty-hint {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
.matchup-link {
color: var(--primary-link);
}
.bet-stat-active {
color: var(--success-text);
font-weight: 600;
}
.bet-stat-zero {
color: #aaa49a;
}
.action-btns {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
width: 100%;
}
.action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4px 5px;
min-width: 0;
width: 100%;
padding: 4px 6px;
box-sizing: border-box;
border-radius: 8px;
background: #ffffff;
border: 1px solid var(--border-soft);
}
.action-row--primary {
background: #fbfaf7;
}
.action-row--workflow {
background: #ffffff;
}
.action-btns :deep(.el-button) {
margin: 0 !important;
min-width: 48px;
min-height: 24px !important;
height: auto !important;
padding: 4px 9px !important;
font-size: 12px !important;
line-height: 1.2 !important;
border-radius: 6px;
white-space: nowrap;
}
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled)) {
background: var(--info-bg) !important;
border-color: var(--info-border) !important;
color: var(--info-text) !important;
}
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):hover),
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):focus) {
background: #d5ecfb !important;
border-color: #9fd0ed !important;
color: #155b86 !important;
}
.action-btns :deep(.el-button:not(.is-disabled):not(:disabled)) {
cursor: pointer;
}
.action-btns :deep(.el-button.is-disabled),
.action-btns :deep(.el-button:disabled) {
cursor: not-allowed;
}
@media (max-width: 760px) {
.league-matches-panel {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
overflow-x: auto;
}
.nested-panel-toolbar,
.nested-pager {
justify-content: flex-start;
}
.action-btns {
justify-content: flex-start;
}
}
.match-markets-dialog__body {
height: min(78vh, 900px);
overflow: hidden;
}
</style>