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

@@ -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;