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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user