feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档

重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { VChart } from '../components/dashboard/echarts-setup';
const VChart = defineAsyncComponent(() =>
import('../components/dashboard/echarts-setup').then((m) => m.VChart),
);
import { formatAmount } from '../utils/format-amount';
import {
buildBetTypePieOption,
@@ -72,11 +75,22 @@ const loading = ref(false);
const previewing = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const winnerTeamId = ref('');
const outrightSelections = ref<
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
>([]);
const preview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<Record<string, unknown> | null>(null);
const resettleReason = ref('');
const stats = ref<SettlementBetStats | null>(null);
const statsSummary = ref<Pick<SettlementBetStats, 'summary' | 'bySelection'> | null>(null);
const betsList = ref<SettlementBetStats['bets'] | null>(null);
const statsLoading = ref(false);
const betsLoading = ref(false);
const stats = computed((): SettlementBetStats | null => {
if (!statsSummary.value || !betsList.value) return null;
return { ...statsSummary.value, bets: betsList.value };
});
const betPage = ref(1);
const betPageSize = ref(10);
const previewPage = ref(1);
@@ -85,6 +99,42 @@ const previewPageSize = ref(10);
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score
const matchId = computed(() => String(route.params.id ?? ''));
const isOutright = computed(() => match.value?.isOutright === true);
const outrightTitle = computed(() => {
const m = match.value;
if (!m) return '';
const name = m.matchName?.trim();
if (name) return name;
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
return m.leagueZh || m.leagueEn;
});
const settlementPageTitle = computed(() =>
isOutright.value ? t('settlement.outright.page_title') : t('page.settlement.title'),
);
function outrightTeamLabel(row: { teamZh: string; teamEn: string; teamCode: string }) {
const name =
locale.value === 'en-US'
? row.teamEn || row.teamZh
: locale.value === 'ms-MY'
? row.teamEn || row.teamZh
: row.teamZh || row.teamEn;
return `${name} (${row.teamCode})`;
}
function buildSettlementPayload(): Record<string, unknown> | null {
if (isOutright.value) {
if (!winnerTeamId.value) {
ElMessage.warning(t('settlement.outright.winner_required'));
return null;
}
return { winnerTeamId: Number(winnerTeamId.value) };
}
return { ...score.value };
}
type PreviewItem = {
betNo: string;
betType: string;
@@ -258,16 +308,12 @@ function matchBetSelectionSummary(
.join(' · ');
}
async function loadStats() {
async function loadStatsSummary() {
if (!matchId.value) return;
statsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
stats.value = data.data as SettlementBetStats;
betPage.value = stats.value.bets.page;
betPageSize.value = stats.value.bets.pageSize;
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/summary`);
statsSummary.value = data.data as Pick<SettlementBetStats, 'summary' | 'bySelection'>;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
@@ -276,15 +322,39 @@ async function loadStats() {
}
}
async function loadBets() {
if (!matchId.value) return;
betsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/bets`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
const payload = data.data as SettlementBetStats['bets'];
betsList.value = payload;
betPage.value = payload.page;
betPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
betsLoading.value = false;
}
}
async function loadStats() {
betPage.value = 1;
await Promise.all([loadStatsSummary(), loadBets()]);
}
function onBetPageChange(page: number) {
betPage.value = page;
void loadStats();
void loadBets();
}
function onBetPageSizeChange(size: number) {
betPageSize.value = size;
betPage.value = 1;
void loadStats();
void loadBets();
}
async function loadMatch() {
@@ -292,26 +362,49 @@ async function loadMatch() {
loading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}`);
const detail = data.data as AdminMatchDetail & {
score?: { htHome: number; htAway: number; ftHome: number; ftAway: number } | null;
};
if (detail.isOutright) {
ElMessage.warning(t('msg.outright_no_edit'));
router.replace('/matches');
return;
}
const detail = data.data as AdminMatchDetail;
const settleable =
detail.status === 'CLOSED' ||
detail.status === 'PENDING_SETTLEMENT' ||
detail.status === 'SETTLED';
if (!settleable) {
ElMessage.warning(t('settlement.must_close_first'));
router.replace('/matches');
router.replace(detail.isOutright ? '/matches/outrights' : '/matches');
return;
}
match.value = detail;
if (detail.score) {
score.value = { ...detail.score };
score.value = {
htHome: detail.score.htHome,
htAway: detail.score.htAway,
ftHome: detail.score.ftHome,
ftAway: detail.score.ftAway,
};
winnerTeamId.value = detail.score.winnerTeamId ?? '';
} else {
winnerTeamId.value = '';
}
if (detail.isOutright) {
const outrightRes = await api.get(`/admin/outrights/${matchId.value}`);
const payload = outrightRes.data.data as {
selections: Array<{
teamId: string | null;
teamCode: string;
teamZh: string;
teamEn: string;
status: string;
}>;
};
outrightSelections.value = (payload.selections ?? [])
.filter((s) => s.teamId)
.map((s) => ({
teamId: s.teamId as string,
teamCode: s.teamCode,
teamZh: s.teamZh,
teamEn: s.teamEn,
}));
} else {
outrightSelections.value = [];
}
betPage.value = 1;
await loadStats();
@@ -326,8 +419,10 @@ async function loadMatch() {
const isSettled = computed(() => match.value?.status === 'SETTLED');
async function previewResettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...score.value,
...payload,
reason: resettleReason.value.trim() || undefined,
});
resettlePreview.value = data.data;
@@ -347,6 +442,9 @@ function settlementApiError(e: unknown, fallback: string) {
if (raw === 'Score not recorded' || raw === 'Score not found') {
return t('settlement.err_score_not_recorded');
}
if (raw === 'SETTLEMENT_WINNER_REQUIRED') {
return t('settlement.outright.winner_required');
}
return raw;
}
@@ -368,12 +466,14 @@ async function loadPreviewItems(page = previewPage.value, pageSize = previewPage
}
async function previewSettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
preview.value = null;
previewPage.value = 1;
previewing.value = true;
try {
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
...score.value,
...payload,
page: 1,
pageSize: previewPageSize.value,
});
@@ -416,7 +516,7 @@ onMounted(() => {
<template>
<div v-loading="loading" class="settlement-page">
<AdminSubNav
:title="t('page.settlement.title')"
:title="settlementPageTitle"
:subtitle="`#${matchId}`"
>
<template #extra>
@@ -426,7 +526,13 @@ onMounted(() => {
<el-card v-if="match" class="settle-top-card" shadow="never">
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
<div class="match-inline">
<div v-if="isOutright" class="outright-settle-head">
<p class="outright-settle-title">{{ outrightTitle }}</p>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.outright.title') }}</span>
</span>
</div>
<div v-else class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
@@ -455,7 +561,24 @@ onMounted(() => {
</div>
<div class="settle-score-row">
<div class="score-inline-group">
<div v-if="isOutright" class="outright-winner-row">
<span class="score-title">{{ t('settlement.outright.winner') }}</span>
<el-select
v-model="winnerTeamId"
filterable
clearable
class="outright-winner-select"
:placeholder="t('settlement.outright.winner_ph')"
>
<el-option
v-for="row in outrightSelections"
:key="row.teamId"
:label="outrightTeamLabel(row)"
:value="row.teamId"
/>
</el-select>
</div>
<div v-else class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
@@ -483,7 +606,9 @@ onMounted(() => {
>
{{ t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
<span class="preview-hint">{{
isOutright ? t('settlement.outright.preview_hint') : t('settlement.preview_hint')
}}</span>
</template>
<template v-else>
<el-input
@@ -638,7 +763,7 @@ onMounted(() => {
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
</div>
<div class="table-wrap">
<div v-loading="betsLoading" class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
@@ -751,6 +876,32 @@ onMounted(() => {
border-top: 1px solid #2a2a2a;
}
.outright-settle-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
}
.outright-settle-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--green-text);
}
.outright-winner-row {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 240px;
flex: 1;
}
.outright-winner-select {
width: min(100%, 360px);
}
.settle-actions {
display: flex;
align-items: center;