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

@@ -27,6 +27,7 @@ const emit = defineEmits<{
deposit: [];
withdraw: [];
freeze: [];
delete: [];
}>();
const { t } = useAdminLocale();
@@ -58,6 +59,9 @@ const { t } = useAdminLocale();
<el-button v-else link type="primary" size="small" @click="emit('freeze')">
{{ t('common.unfreeze') }}
</el-button>
<el-button link type="danger" size="small" @click="emit('delete')">
{{ t('common.delete') }}
</el-button>
</template>
<template #menu>
<el-dropdown-item v-if="showDetail" @click="emit('detail')">{{ t('common.detail') }}</el-dropdown-item>
@@ -71,6 +75,9 @@ const { t } = useAdminLocale();
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else @click="emit('freeze')">{{ t('common.unfreeze') }}</el-dropdown-item>
<el-dropdown-item divided @click="emit('delete')">
<span class="action-danger">{{ t('common.delete') }}</span>
</el-dropdown-item>
</template>
</AdminResponsiveRowActions>
</template>
@@ -79,4 +86,7 @@ const { t } = useAdminLocale();
:deep(.action-warning) {
color: var(--el-color-warning);
}
:deep(.action-danger) {
color: var(--el-color-danger);
}
</style>

View File

@@ -287,7 +287,6 @@ watch(
<div class="table-wrap">
<el-table
v-loading="loading"
:key="locale"
:data="items"
stripe
size="small"

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
type LeagueBlockingMatch = {
id: string;
status: string;
isOutright: boolean;
title: string;
pendingCount: number;
};
type LeagueArchivePreview = {
leagueId: string;
canArchive: boolean;
blockingMatches: LeagueBlockingMatch[];
totalPendingBets: number;
};
const props = defineProps<{
modelValue: boolean;
leagueId: string;
leagueName?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
archived: [];
}>();
const { t } = useAdminLocale();
const loading = ref(false);
const submitting = ref(false);
const preview = ref<LeagueArchivePreview | null>(null);
const displayName = computed(() => props.leagueName || props.leagueId);
watch(
() => [props.modelValue, props.leagueId] as const,
([open]) => {
if (open && props.leagueId) void loadPreview();
},
);
function close() {
emit('update:modelValue', false);
}
async function loadPreview() {
loading.value = true;
preview.value = null;
try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/archive-preview`);
preview.value = data.data as LeagueArchivePreview;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
close();
} finally {
loading.value = false;
}
}
async function confirmArchive() {
if (!preview.value?.canArchive) return;
submitting.value = true;
try {
await api.post(`/admin/leagues/${props.leagueId}/archive`);
ElMessage.success(t('archive.league_done'));
close();
emit('archived');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
} finally {
submitting.value = false;
}
}
function matchTypeLabel(row: LeagueBlockingMatch) {
return row.isOutright ? t('nav.outrights') : t('match.col.matchup');
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('archive.league_title')"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<p class="archive-target">{{ t('archive.league_target', { name: displayName }) }}</p>
<p class="archive-hint">{{ t('archive.league_hint') }}</p>
<template v-if="preview">
<el-alert
v-if="!preview.canArchive"
type="warning"
:closable="false"
show-icon
class="archive-alert"
>
{{ t('archive.league_blocked') }}
</el-alert>
<el-table
v-if="preview.blockingMatches.length"
:data="preview.blockingMatches"
size="small"
stripe
class="blocking-table"
>
<el-table-column prop="title" :label="t('match.col.matchup')" min-width="160" />
<el-table-column :label="t('common.type')" width="88">
<template #default="{ row }">{{ matchTypeLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.pending_bets')" width="88" align="center">
<template #default="{ row }">{{ row.pendingCount }}</template>
</el-table-column>
</el-table>
<p v-else-if="preview.canArchive" class="archive-ok">{{ t('archive.league_ready') }}</p>
</template>
</div>
<template #footer>
<el-button @click="close">{{ t('common.cancel') }}</el-button>
<el-button
type="danger"
:disabled="!preview?.canArchive"
:loading="submitting"
@click="confirmArchive"
>
{{ t('archive.league_confirm') }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.archive-target {
margin: 0 0 8px;
font-weight: 600;
}
.archive-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.archive-alert {
margin-bottom: 12px;
}
.blocking-table {
margin-bottom: 8px;
}
.archive-ok {
margin: 0;
color: var(--el-color-success);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount } from '../utils/format-amount';
type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH';
type MatchArchivePreview = {
matchId: string;
matchStatus: string;
isOutright: boolean;
title: string;
pendingBetCount: number;
pendingStake: string;
hasPreviewSettlementBatch: boolean;
requiresForce: boolean;
warnings: MatchArchiveWarning[];
};
const props = defineProps<{
modelValue: boolean;
matchId: string;
title?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
archived: [];
}>();
const { t } = useAdminLocale();
const loading = ref(false);
const submitting = ref(false);
const preview = ref<MatchArchivePreview | null>(null);
const refundPendingBets = ref(false);
const displayTitle = computed(() => props.title || preview.value?.title || props.matchId);
watch(
() => [props.modelValue, props.matchId] as const,
([open]) => {
if (open && props.matchId) void loadPreview();
},
);
function close() {
emit('update:modelValue', false);
}
async function loadPreview() {
loading.value = true;
preview.value = null;
try {
const { data } = await api.get(`/admin/matches/${props.matchId}/archive-preview`);
preview.value = data.data as MatchArchivePreview;
refundPendingBets.value = (preview.value.pendingBetCount ?? 0) > 0;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
close();
} finally {
loading.value = false;
}
}
async function confirmArchive(force: boolean) {
if (!preview.value) return;
submitting.value = true;
try {
const { data } = await api.post(`/admin/matches/${props.matchId}/archive`, {
force,
refundPendingBets: refundPendingBets.value,
});
const voided = (data.data as { voidedCount?: number })?.voidedCount ?? 0;
ElMessage.success(
voided > 0 ? t('archive.msg_done_refund', { n: voided }) : t('archive.msg_done'),
);
close();
emit('archived');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
} finally {
submitting.value = false;
}
}
function warningLabel(code: MatchArchiveWarning) {
return t(`archive.warning.${code}`);
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('archive.match_title')"
width="520px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<p class="archive-target">{{ t('archive.target', { title: displayTitle }) }}</p>
<template v-if="preview">
<ul v-if="preview.warnings.length" class="archive-warnings">
<li v-for="w in preview.warnings" :key="w">{{ warningLabel(w) }}</li>
</ul>
<p v-if="preview.pendingBetCount > 0" class="archive-stat">
{{
t('archive.pending_summary', {
count: preview.pendingBetCount,
stake: formatAmount(preview.pendingStake),
})
}}
</p>
<p class="archive-hint">{{ t('archive.soft_delete_hint') }}</p>
<el-checkbox
v-if="preview.pendingBetCount > 0"
v-model="refundPendingBets"
class="archive-refund"
>
{{ t('archive.refund_pending') }}
</el-checkbox>
<p v-if="preview.pendingBetCount > 0 && refundPendingBets" class="archive-parlay-hint">
{{ t('archive.parlay_void_hint') }}
</p>
</template>
</div>
<template #footer>
<el-button @click="close">{{ t('common.cancel') }}</el-button>
<template v-if="preview">
<el-button
v-if="!preview.requiresForce"
type="danger"
:loading="submitting"
@click="confirmArchive(false)"
>
{{ t('common.delete') }}
</el-button>
<el-button
v-else
type="danger"
:loading="submitting"
@click="confirmArchive(true)"
>
{{ t('archive.force_delete') }}
</el-button>
</template>
</template>
</el-dialog>
</template>
<style scoped>
.archive-target {
margin: 0 0 12px;
font-weight: 600;
}
.archive-warnings {
margin: 0 0 12px;
padding-left: 18px;
color: var(--el-color-warning);
}
.archive-stat {
margin: 0 0 8px;
font-size: 13px;
}
.archive-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.archive-refund {
display: flex;
margin-bottom: 8px;
}
.archive-parlay-hint {
margin: 0;
font-size: 12px;
color: var(--el-color-warning);
}
</style>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue';
import { VChart, type EChartsOption } from './echarts-setup';
import { computed, defineAsyncComponent } from 'vue';
import type { EChartsOption } from 'echarts';
const VChart = defineAsyncComponent(() =>
import('./echarts-setup').then((m) => m.VChart),
);
const props = withDefaults(
defineProps<{
@@ -17,7 +21,9 @@ const style = computed(() => ({ height: props.height, width: '100%' }));
<template>
<div class="chart-panel">
<div v-if="title" class="chart-title">{{ title }}</div>
<VChart class="chart-canvas" :option="option" :style="style" autoresize />
<Suspense>
<VChart class="chart-canvas" :option="option" :style="style" autoresize />
</Suspense>
</div>
</template>