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:
@@ -4,6 +4,7 @@ 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<{
|
||||
@@ -19,8 +20,15 @@ const emit = defineEmits<{
|
||||
|
||||
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;
|
||||
@@ -30,9 +38,20 @@ async function load() {
|
||||
status: props.filterStatus || undefined,
|
||||
keyword: props.keyword.trim() || undefined,
|
||||
locale: locale.value,
|
||||
page: matchPage.value,
|
||||
pageSize: matchPageSize.value,
|
||||
},
|
||||
});
|
||||
matches.value = data.data.items;
|
||||
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'));
|
||||
@@ -41,12 +60,32 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
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, locale.value] as const,
|
||||
() => load(),
|
||||
() => [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();
|
||||
@@ -72,6 +111,21 @@ async function publish(id: string) {
|
||||
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'));
|
||||
@@ -156,12 +210,24 @@ function canManage(row: unknown) {
|
||||
const s = matchStatus(row);
|
||||
return s === 'DRAFT' || s === 'PUBLISHED';
|
||||
}
|
||||
function canDeleteRow(row: unknown) {
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
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';
|
||||
}
|
||||
@@ -242,22 +308,7 @@ async function reopenRow(row: unknown) {
|
||||
}
|
||||
|
||||
async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
notifyParent();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
openArchive(row);
|
||||
}
|
||||
|
||||
defineExpose({ reload: load });
|
||||
@@ -328,13 +379,21 @@ defineExpose({ reload: load });
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<el-button
|
||||
v-if="canPublishRow(row)"
|
||||
size="small"
|
||||
type="success"
|
||||
:disabled="!canPublishRow(row)"
|
||||
@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"
|
||||
@@ -365,7 +424,6 @@ defineExpose({ reload: load });
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="!canDeleteRow(row)"
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
@@ -375,6 +433,24 @@ defineExpose({ reload: load });
|
||||
</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>
|
||||
|
||||
@@ -383,6 +459,11 @@ defineExpose({ reload: load });
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
@@ -48,6 +49,9 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t, locale } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const matchStatus = ref('');
|
||||
|
||||
function teamDisplayName(row: { teamCode: string; teamZh: string; teamEn: string }) {
|
||||
return teamRowDisplayName(row, locale.value);
|
||||
@@ -55,8 +59,11 @@ function teamDisplayName(row: { teamCode: string; teamZh: string; teamEn: string
|
||||
|
||||
const loading = ref(false);
|
||||
const savingOdds = ref(false);
|
||||
const reopening = ref(false);
|
||||
const adding = ref(false);
|
||||
const matchId = ref('');
|
||||
const leagueIsPublished = ref(true);
|
||||
const unsettledFixtureCount = ref(0);
|
||||
const selections = ref<SelectionRow[]>([]);
|
||||
|
||||
const addVisible = ref(false);
|
||||
@@ -133,6 +140,95 @@ const sortedSelections = computed(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const canCloseOutright = computed(() => matchStatus.value === 'PUBLISHED');
|
||||
const canReopenOutright = computed(() =>
|
||||
['CLOSED', 'PENDING_SETTLEMENT'].includes(matchStatus.value),
|
||||
);
|
||||
const canSettleOutright = computed(() =>
|
||||
['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'].includes(matchStatus.value),
|
||||
);
|
||||
const canProceedSettle = computed(
|
||||
() => matchStatus.value === 'SETTLED' || unsettledFixtureCount.value === 0,
|
||||
);
|
||||
const showLeagueUnpublishedHint = computed(
|
||||
() => !leagueIsPublished.value && matchStatus.value === 'DRAFT',
|
||||
);
|
||||
const showUnsettledFixturesHint = computed(
|
||||
() =>
|
||||
unsettledFixtureCount.value > 0 &&
|
||||
['CLOSED', 'PENDING_SETTLEMENT'].includes(matchStatus.value),
|
||||
);
|
||||
const settleButtonLabel = computed(() =>
|
||||
matchStatus.value === 'SETTLED' ? t('common.resettle') : t('common.settle'),
|
||||
);
|
||||
const statusLabel = computed(() => {
|
||||
const key = `match.status.${matchStatus.value}`;
|
||||
const label = t(key);
|
||||
return label === key ? matchStatus.value : label;
|
||||
});
|
||||
const statusTagType = computed(() => {
|
||||
switch (matchStatus.value) {
|
||||
case 'PUBLISHED':
|
||||
return 'success';
|
||||
case 'CLOSED':
|
||||
case 'PENDING_SETTLEMENT':
|
||||
return 'warning';
|
||||
case 'SETTLED':
|
||||
return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
});
|
||||
|
||||
async function reopenOutright() {
|
||||
if (!matchId.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(t('outright.confirm_reopen'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
reopening.value = true;
|
||||
try {
|
||||
await api.post(`/admin/matches/${matchId.value}/reopen`);
|
||||
ElMessage.success(t('msg.reopened'));
|
||||
await load();
|
||||
emit('updated');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
reopening.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeOutright() {
|
||||
if (!matchId.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(t('outright.confirm_close'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await api.post(`/admin/matches/${matchId.value}/close`);
|
||||
ElMessage.success(t('msg.closed'));
|
||||
await load();
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
function goSettle() {
|
||||
if (!matchId.value) return;
|
||||
if (!canProceedSettle.value) {
|
||||
ElMessage.warning(
|
||||
t('outright.unsettled_fixtures_hint', { n: unsettledFixtureCount.value }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void router.push(`/settlement/${matchId.value}`);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.leagueId) return;
|
||||
loading.value = true;
|
||||
@@ -140,6 +236,9 @@ async function load() {
|
||||
const { data } = await api.get(`/admin/leagues/${props.leagueId}/outright`);
|
||||
const payload = data.data as {
|
||||
id: string;
|
||||
status?: string;
|
||||
leagueIsPublished?: boolean;
|
||||
unsettledFixtureCount?: number;
|
||||
fixtureSyncAdded?: number;
|
||||
fixtureSyncReopened?: number;
|
||||
selections: Array<{
|
||||
@@ -154,6 +253,9 @@ async function load() {
|
||||
}>;
|
||||
};
|
||||
matchId.value = payload.id;
|
||||
matchStatus.value = payload.status ?? '';
|
||||
leagueIsPublished.value = payload.leagueIsPublished ?? true;
|
||||
unsettledFixtureCount.value = payload.unsettledFixtureCount ?? 0;
|
||||
selections.value = (payload.selections ?? [])
|
||||
.filter((s) => s.status === 'OPEN')
|
||||
.map((s) => ({
|
||||
@@ -472,8 +574,48 @@ watch(
|
||||
<template>
|
||||
<div v-loading="loading" class="outright-odds-panel">
|
||||
<div class="outright-odds-panel__head">
|
||||
<p class="outright-odds-panel__hint">{{ t('outright.odds_only_hint') }}</p>
|
||||
<div class="outright-odds-panel__head-text">
|
||||
<p class="outright-odds-panel__hint">{{ t('outright.odds_only_hint') }}</p>
|
||||
<p v-if="showLeagueUnpublishedHint" class="outright-odds-panel__workflow-hint">
|
||||
{{ t('outright.league_unpublished_hint') }}
|
||||
</p>
|
||||
<p v-if="showUnsettledFixturesHint" class="outright-odds-panel__workflow-hint">
|
||||
{{ t('outright.unsettled_fixtures_hint', { n: unsettledFixtureCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="outright-odds-panel__actions">
|
||||
<el-tag v-if="matchStatus" size="small" :type="statusTagType" effect="dark">
|
||||
{{ statusLabel }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="canCloseOutright"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
@click="closeOutright"
|
||||
>
|
||||
{{ t('outright.btn.close') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canReopenOutright"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:loading="reopening"
|
||||
@click="reopenOutright"
|
||||
>
|
||||
{{ t('outright.btn.reopen') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canSettleOutright"
|
||||
type="success"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="!canProceedSettle"
|
||||
@click="goSettle"
|
||||
>
|
||||
{{ settleButtonLabel }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="selections.length"
|
||||
:type="batchMode ? 'warning' : 'default'"
|
||||
@@ -763,13 +905,17 @@ watch(
|
||||
}
|
||||
.outright-odds-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.outright-odds-panel__head-text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.outright-odds-panel__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -782,6 +928,12 @@ watch(
|
||||
color: #777;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.outright-odds-panel__workflow-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.outright-odds-panel__batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
|
||||
|
||||
export interface LeagueOutrightSummary {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
const archiveVisible = ref(false);
|
||||
|
||||
const loading = ref(false);
|
||||
const applying = ref(false);
|
||||
@@ -52,6 +54,16 @@ function goEdit() {
|
||||
router.push({ name: 'admin-outright-edit', params: { matchId: props.event.id } });
|
||||
}
|
||||
|
||||
function goSettle() {
|
||||
if (!props.event) return;
|
||||
router.push(`/settlement/${props.event.id}`);
|
||||
}
|
||||
|
||||
const canSettleOutright = computed(() => {
|
||||
const s = props.event?.status;
|
||||
return s === 'CLOSED' || s === 'PENDING_SETTLEMENT' || s === 'SETTLED';
|
||||
});
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.event) {
|
||||
selections.value = [];
|
||||
@@ -96,6 +108,15 @@ watch(
|
||||
() => loadDetail(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function openArchive() {
|
||||
if (!props.event) return;
|
||||
archiveVisible.value = true;
|
||||
}
|
||||
|
||||
function onOutrightArchived() {
|
||||
emit('updated');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,6 +145,9 @@ watch(
|
||||
<el-button type="primary" size="small" @click="goEdit">
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button v-if="canSettleOutright" size="small" @click="goSettle">
|
||||
{{ t('outright.btn.settle') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="event.canImportCanonical"
|
||||
size="small"
|
||||
@@ -132,6 +156,9 @@ watch(
|
||||
>
|
||||
{{ t('outright.btn.apply_canonical') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="openArchive">
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" plain size="small" @click="emit('create')">
|
||||
{{ t('match.outright.setup') }}
|
||||
@@ -161,6 +188,13 @@ watch(
|
||||
</el-table>
|
||||
<p v-else-if="!loading" class="meta-empty">{{ t('outright.expand_no_teams') }}</p>
|
||||
</div>
|
||||
<MatchArchiveDialog
|
||||
v-if="event"
|
||||
v-model="archiveVisible"
|
||||
:match-id="event.id"
|
||||
:title="event.matchName || t('nav.outrights')"
|
||||
@archived="onOutrightArchived"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -234,6 +234,11 @@ async function saveMeta() {
|
||||
<el-switch v-model="form.isHot" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.correct_score_enabled')">
|
||||
<el-switch v-model="form.correctScoreEnabled" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
Reference in New Issue
Block a user