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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { ref, computed, onMounted, watch, reactive, h } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -181,10 +181,15 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
}
/* ─── Agent row expansion (direct players) ─── */
const MAX_EXPANDED_AGENT_ROWS = 2;
const expandedSet = ref(new Set<string>());
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
const agentPlayersMeta = ref<
Record<string, { total: number; page: number; pageSize: number }>
>({});
const expandLoading = ref<Record<string, boolean>>({});
const EXPAND_PLAYER_PAGE_SIZE = 20;
const createVisible = ref(false);
const createLoading = ref(false);
@@ -439,7 +444,8 @@ function openPlayerWalletLedger(
/* ─── Agent row expansion ─── */
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
const ids = expandedRows.map((r) => r.userId).slice(0, MAX_EXPANDED_AGENT_ROWS);
expandedSet.value = new Set(ids);
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
@@ -452,19 +458,41 @@ function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: Mous
if (next.has(userId)) {
next.delete(userId);
} else {
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
const [first] = next;
if (first) next.delete(first);
}
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentUserId: string) {
async function loadExpansionData(agentUserId: string, page = 1) {
expandLoading.value[agentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${agentUserId}/players`);
agentPlayersMap.value[agentUserId] = (data.data ?? []) as ScopedPlayerRow[];
const { data } = await api.get(`/agent/agents/${agentUserId}/players`, {
params: { page, pageSize: EXPAND_PLAYER_PAGE_SIZE },
});
const payload = data.data as {
items: ScopedPlayerRow[];
total: number;
page: number;
pageSize: number;
};
agentPlayersMap.value[agentUserId] = payload.items ?? [];
agentPlayersMeta.value[agentUserId] = {
total: payload.total ?? 0,
page: payload.page ?? page,
pageSize: payload.pageSize ?? EXPAND_PLAYER_PAGE_SIZE,
};
} catch {
agentPlayersMap.value[agentUserId] = [];
agentPlayersMeta.value[agentUserId] = {
total: 0,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
};
} finally {
expandLoading.value[agentUserId] = false;
}
@@ -474,6 +502,20 @@ function getPlayers(agentUserId: string) {
return agentPlayersMap.value[agentUserId] ?? [];
}
function getPlayersMeta(agentUserId: string) {
return (
agentPlayersMeta.value[agentUserId] ?? {
total: getPlayers(agentUserId).length,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
}
);
}
function onExpandPlayersPageChange(agentUserId: string, page: number) {
void loadExpansionData(agentUserId, page);
}
function refreshExpandedAgentPlayers() {
for (const uid of expandedSet.value) {
void loadExpansionData(uid);
@@ -580,6 +622,60 @@ async function toggleFreeze(row: ScopedPlayerRow) {
}
}
/* ─── Delete ─── */
async function deletePlayerRow(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
await ElMessageBox.confirm(
t('msg.delete_player_body', { name: row.username }),
t('msg.delete_player_title'),
{
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
},
);
} catch { return; }
const input = ref('');
try {
await ElMessageBox({
title: t('msg.delete_player_confirm_title'),
message: () =>
h('div', {}, [
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
t('msg.delete_player_confirm_hint', { name: row.username })),
h('input', {
value: input.value,
placeholder: row.username,
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
beforeClose: (action: string, _instance: unknown, done: () => void) => {
if (action === 'confirm') {
if (input.value.trim() !== row.username) {
ElMessage.warning(t('msg.delete_player_mismatch'));
return;
}
}
done();
},
});
} catch { return; }
try {
await api.delete(`/agent/players/${row.id}`);
ElMessage.success(t('msg.delete_player_done'));
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
}
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
@@ -938,6 +1034,7 @@ function statusTagType(s: string) {
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
@delete="deletePlayerRow(row)"
/>
<el-button
v-else-if="canViewPlayer(row)"
@@ -1040,7 +1137,7 @@ function statusTagType(s: string) {
<div v-else class="expand-panel-body">
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
<div class="expand-section-title">
{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}
{{ directPlayersTabLabel(row.username, getPlayersMeta(row.userId).total) }}
</div>
<el-table
:data="getPlayers(row.userId)"
@@ -1079,6 +1176,19 @@ function statusTagType(s: string) {
<template #default="{ row: player }">{{ formatTime(player.createdAt) }}</template>
</el-table-column>
</el-table>
<div
v-if="getPlayersMeta(row.userId).total > getPlayersMeta(row.userId).pageSize"
class="expand-pager"
>
<el-pagination
small
layout="total, prev, pager, next"
:total="getPlayersMeta(row.userId).total"
:current-page="getPlayersMeta(row.userId).page"
:page-size="getPlayersMeta(row.userId).pageSize"
@current-change="(p: number) => onExpandPlayersPageChange(row.userId, p)"
/>
</div>
</div>
</div>
</template>
@@ -1480,6 +1590,11 @@ function statusTagType(s: string) {
.expand-section-title--spaced { margin-top: 14px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
.expand-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* ─── Shared ─── */
.field-hint { margin-top: 6px; font-size: 12px; color: #666; line-height: 1.4; }