重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
551 lines
15 KiB
Vue
551 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, h } from 'vue';
|
|
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 { ensureLeagueExpanded } from '../../utils/matchesListState';
|
|
import { formatAmount } from '../../utils/format-amount';
|
|
const props = defineProps<{
|
|
leagueId: string;
|
|
filterStatus: string;
|
|
keyword: string;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
changed: [];
|
|
'add-match': [];
|
|
}>();
|
|
|
|
const { t, locale } = useAdminLocale();
|
|
const router = useRouter();
|
|
const archiveVisible = ref(false);
|
|
const archiveMatchId = ref('');
|
|
const archiveTitle = ref('');
|
|
const matches = ref<unknown[]>([]);
|
|
const loading = ref(false);
|
|
const matchPage = ref(1);
|
|
const matchPageSize = ref(20);
|
|
const matchTotal = ref(0);
|
|
let loadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
|
|
params: {
|
|
status: props.filterStatus || undefined,
|
|
keyword: props.keyword.trim() || undefined,
|
|
locale: locale.value,
|
|
page: matchPage.value,
|
|
pageSize: matchPageSize.value,
|
|
},
|
|
});
|
|
const payload = data.data as {
|
|
items: unknown[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
};
|
|
matches.value = payload.items;
|
|
matchTotal.value = payload.total;
|
|
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 {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function scheduleLoad(resetPage = false) {
|
|
if (resetPage) matchPage.value = 1;
|
|
if (loadTimer) clearTimeout(loadTimer);
|
|
loadTimer = setTimeout(() => {
|
|
loadTimer = null;
|
|
void load();
|
|
}, 200);
|
|
}
|
|
|
|
watch(
|
|
() => [props.leagueId, props.filterStatus, props.keyword] as const,
|
|
() => scheduleLoad(true),
|
|
{ immediate: true },
|
|
);
|
|
|
|
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 beforeLeaveList() {
|
|
ensureLeagueExpanded(props.leagueId);
|
|
}
|
|
|
|
function openManage(id: string) {
|
|
beforeLeaveList();
|
|
router.push(`/matches/${id}/edit`);
|
|
}
|
|
|
|
function openMarkets(id: string) {
|
|
beforeLeaveList();
|
|
router.push(`/matches/${id}/markets`);
|
|
}
|
|
|
|
function settle(id: string) {
|
|
beforeLeaveList();
|
|
router.push(`/settlement/${id}`);
|
|
}
|
|
|
|
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 new Date(String(rowOf(row).startTime)).toLocaleString();
|
|
}
|
|
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(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') {
|
|
if (!kickoff.value || new Date(kickoff.value) <= new Date()) {
|
|
ElMessage.warning(t('match.reopen_kickoff_invalid'));
|
|
return;
|
|
}
|
|
}
|
|
done();
|
|
},
|
|
});
|
|
return 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">
|
|
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
|
<el-table-column prop="id" label="ID" width="64" />
|
|
<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="150">
|
|
<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 width="460" align="center">
|
|
<template #header>
|
|
<div class="actions-col-header">
|
|
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
|
|
<el-button type="primary" size="small" @click.stop="emit('add-match')">
|
|
{{ t('match.create_fixture_btn') }}
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
<template #default="{ row }">
|
|
<div class="action-btns">
|
|
<div class="action-group">
|
|
<el-button
|
|
size="small"
|
|
type="primary"
|
|
:disabled="!canManage(row)"
|
|
@click="openManage(matchId(row))"
|
|
>
|
|
{{ t('matchEditor.manage_btn') }}
|
|
</el-button>
|
|
<el-button
|
|
size="small"
|
|
type="primary"
|
|
plain
|
|
:disabled="!canManage(row)"
|
|
@click="openMarkets(matchId(row))"
|
|
>
|
|
{{ t('match.btn.markets') }}
|
|
</el-button>
|
|
</div>
|
|
<div class="action-group">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
<el-button
|
|
size="small"
|
|
type="danger"
|
|
plain
|
|
@click="confirmDelete(row)"
|
|
>
|
|
{{ t('common.delete') }}
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
|
|
<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"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.league-matches-panel {
|
|
padding: 10px 12px 12px;
|
|
background: #0a0a0a;
|
|
}
|
|
.nested-pager {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
padding-top: 8px;
|
|
}
|
|
.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: 600;
|
|
color: #aaa;
|
|
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) {
|
|
overflow: visible;
|
|
}
|
|
.empty-hint {
|
|
font-size: 12px;
|
|
color: #666;
|
|
margin: 8px 0 0;
|
|
}
|
|
.matchup-link {
|
|
color: var(--green-text);
|
|
}
|
|
.bet-stat-active {
|
|
color: var(--green-text);
|
|
font-weight: 600;
|
|
}
|
|
.bet-stat-zero {
|
|
color: #555;
|
|
}
|
|
.action-btns {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 6px 8px;
|
|
justify-content: center;
|
|
}
|
|
.action-group {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
padding: 2px 4px;
|
|
border-radius: 8px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
}
|
|
.action-btns :deep(.el-button) {
|
|
margin: 0 !important;
|
|
min-width: 52px;
|
|
padding: 4px 10px !important;
|
|
font-size: 12px !important;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|