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:
@@ -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>
|
||||
|
||||
@@ -287,7 +287,6 @@ watch(
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:key="locale"
|
||||
:data="items"
|
||||
stripe
|
||||
size="small"
|
||||
|
||||
166
apps/admin/src/components/LeagueArchiveDialog.vue
Normal file
166
apps/admin/src/components/LeagueArchiveDialog.vue
Normal 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>
|
||||
182
apps/admin/src/components/MatchArchiveDialog.vue
Normal file
182
apps/admin/src/components/MatchArchiveDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user