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:
@@ -143,7 +143,7 @@ watch(
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :key="locale" :data="items" stripe>
|
||||
<el-table :data="items" stripe>
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch, reactive } from 'vue';
|
||||
import { ref, onMounted, computed, watch, reactive, h } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
@@ -219,6 +219,10 @@ const resetAllowed = ref(false);
|
||||
const resetLoading = ref(false);
|
||||
const resetConfirmPhrase = ref('');
|
||||
const settingsCollapseOpen = ref<string[]>([]);
|
||||
const settingsLoaded = ref(false);
|
||||
const resetDbStatusLoaded = ref(false);
|
||||
const agentOptionsLoading = ref(false);
|
||||
const MAX_EXPANDED_AGENT_ROWS = 2;
|
||||
|
||||
const createDialogTitle = computed(() => {
|
||||
if (createAccountMode.value === 1) return t('agent.dialog.create');
|
||||
@@ -383,19 +387,55 @@ function resolveCreateParentLabel(agentId: string) {
|
||||
|
||||
/* ─── Init ─── */
|
||||
onMounted(() => {
|
||||
loadPlayerSettings();
|
||||
loadBettingLimits();
|
||||
loadHierarchySettings();
|
||||
loadPlatformDirectSettings();
|
||||
loadResetDatabaseStatus();
|
||||
loadAgentOptions();
|
||||
void loadUsersPageInit();
|
||||
loadAllPlayers();
|
||||
loadTier1Agents();
|
||||
loadAgentLevelCounts().then(() => {
|
||||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||||
loadSubAgentsAtLevel(lvl);
|
||||
});
|
||||
|
||||
async function loadUsersPageInit() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/users/page-init');
|
||||
const payload = data.data as {
|
||||
playerSettings?: typeof playerSettings.value;
|
||||
bettingLimits?: typeof bettingLimits.value;
|
||||
hierarchySettings?: { maxAgentLevel: number };
|
||||
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
|
||||
agentLevelCounts?: Record<number, number>;
|
||||
};
|
||||
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
|
||||
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
|
||||
if (payload.hierarchySettings) {
|
||||
hierarchySettings.value = {
|
||||
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
if (payload.platformDirect) {
|
||||
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
|
||||
adminInviteRate.value = decimalRateToPercent(
|
||||
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
|
||||
);
|
||||
}
|
||||
if (payload.agentLevelCounts) {
|
||||
agentLevelCounts.value = payload.agentLevelCounts;
|
||||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||||
loadSubAgentsAtLevel(lvl);
|
||||
}
|
||||
}
|
||||
settingsLoaded.value = true;
|
||||
} catch {
|
||||
/* keep defaults */
|
||||
}
|
||||
}
|
||||
|
||||
watch(settingsCollapseOpen, (open) => {
|
||||
if (!open.includes('settings')) return;
|
||||
if (!resetDbStatusLoaded.value) {
|
||||
resetDbStatusLoaded.value = true;
|
||||
void loadResetDatabaseStatus();
|
||||
}
|
||||
if (!settingsLoaded.value) {
|
||||
void loadUsersPageInit();
|
||||
}
|
||||
});
|
||||
|
||||
/* ─── Load tier-1 agents ─── */
|
||||
@@ -513,15 +553,27 @@ async function load() {
|
||||
reloadAgentLists();
|
||||
}
|
||||
|
||||
async function loadAgentOptions() {
|
||||
async function loadAgentOptions(keyword = '') {
|
||||
agentOptionsLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/agents/options');
|
||||
const { data } = await api.get('/admin/agents/options', {
|
||||
params: {
|
||||
keyword: keyword.trim() || undefined,
|
||||
limit: 50,
|
||||
},
|
||||
});
|
||||
agentOptions.value = data.data;
|
||||
} catch {
|
||||
agentOptions.value = [];
|
||||
} finally {
|
||||
agentOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAgentOptionsSearch(keyword: string) {
|
||||
void loadAgentOptions(keyword);
|
||||
}
|
||||
|
||||
async function loadAllPlayers() {
|
||||
playerLoading.value = true;
|
||||
try {
|
||||
@@ -605,6 +657,10 @@ function onAgentRowClick(row: AgentRow, event: MouseEvent) {
|
||||
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);
|
||||
}
|
||||
@@ -1096,6 +1152,64 @@ async function toggleFreezePlayer(row: PlayerRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Delete Player ─── */
|
||||
async function deletePlayer(row: PlayerRow) {
|
||||
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;
|
||||
}
|
||||
// Second confirmation — type username to confirm
|
||||
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(`/admin/users/${row.id}`);
|
||||
ElMessage.success(t('msg.delete_player_done'));
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Freeze / Unfreeze Agent ─── */
|
||||
const freezeAgentIsSuspend = computed(() => {
|
||||
if (!freezeAgentTarget.value) return true;
|
||||
@@ -1347,7 +1461,13 @@ function creditTypeLabel(type: string) {
|
||||
v-model="playerFilterAgent"
|
||||
:placeholder="t('user.filter.agent_ph')"
|
||||
clearable
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="onAgentOptionsSearch"
|
||||
:loading="agentOptionsLoading"
|
||||
style="width: 200px"
|
||||
@focus="() => { if (!agentOptions.length) void loadAgentOptions(); }"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in agentOptions"
|
||||
@@ -1417,6 +1537,7 @@ function creditTypeLabel(type: string) {
|
||||
@deposit="openTransfer('deposit', row)"
|
||||
@withdraw="openTransfer('withdraw', row)"
|
||||
@freeze="toggleFreezePlayer(row)"
|
||||
@delete="deletePlayer(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -1524,6 +1645,7 @@ function creditTypeLabel(type: string) {
|
||||
@deposit="openTransfer('deposit', player)"
|
||||
@withdraw="openTransfer('withdraw', player)"
|
||||
@freeze="toggleFreezePlayer(player)"
|
||||
@delete="deletePlayer(player)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -1679,6 +1801,7 @@ function creditTypeLabel(type: string) {
|
||||
@deposit="openTransfer('deposit', player)"
|
||||
@withdraw="openTransfer('withdraw', player)"
|
||||
@freeze="toggleFreezePlayer(player)"
|
||||
@delete="deletePlayer(player)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -79,7 +79,7 @@ function formatTime(v: string) {
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :key="locale" :data="logs" stripe>
|
||||
<el-table :data="logs" stripe>
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
|
||||
@@ -308,7 +308,7 @@ function batchDelete() {
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
sortOrder: total.value + 1,
|
||||
sortOrder: 0,
|
||||
status: 'DRAFT',
|
||||
linkType: '',
|
||||
linkTarget: '',
|
||||
@@ -381,6 +381,7 @@ async function submitForm() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const isCreate = !editingId.value;
|
||||
if (editingId.value) {
|
||||
const { contentType: _type, ...updateBody } = payload;
|
||||
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||
@@ -389,6 +390,7 @@ async function submitForm() {
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
dialogVisible.value = false;
|
||||
if (isCreate) page.value = 1;
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
|
||||
@@ -471,23 +473,37 @@ void load();
|
||||
<el-button type="primary" plain size="small" @click="openCreate">
|
||||
{{ t('content.btn.create') }}
|
||||
</el-button>
|
||||
<span v-if="hasSelection" class="batch-hint">
|
||||
{{ t('content.batch.selected', { n: selectedRows.length }) }}
|
||||
</span>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchEnable"
|
||||
>
|
||||
{{ t('content.batch.enable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchDisable"
|
||||
>
|
||||
{{ t('content.batch.disable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchDelete"
|
||||
>
|
||||
{{ t('content.batch.delete') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="loading" class="data-card" shadow="never">
|
||||
<div v-if="hasSelection" class="table-toolbar">
|
||||
<span class="batch-hint">{{ t('content.batch.selected', { n: selectedRows.length }) }}</span>
|
||||
<el-button size="small" :disabled="saving" @click="batchEnable">
|
||||
{{ t('content.batch.enable') }}
|
||||
</el-button>
|
||||
<el-button size="small" :disabled="saving" @click="batchDisable">
|
||||
{{ t('content.batch.disable') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" :disabled="saving" @click="batchDelete">
|
||||
{{ t('content.batch.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
@@ -730,22 +746,6 @@ void load();
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #222;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.type-hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
@@ -757,6 +757,13 @@ void load();
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.batch-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
@@ -86,8 +87,33 @@ function openScreenshot(url: string) {
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDepositAction(message: string, title: string): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showApiError(e: unknown) {
|
||||
const msg = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
ElMessage.error(msg || 'Error');
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!approveTarget.value) return;
|
||||
const amountText = formatAmount(String(approveAmount.value));
|
||||
if (!await confirmDepositAction(
|
||||
t('deposit.confirm_approve_message', { amount: amountText }),
|
||||
t('deposit.confirm_approve'),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/admin/deposit-orders/${approveTarget.value.id}/approve`, {
|
||||
approvedAmount: approveAmount.value,
|
||||
@@ -95,15 +121,15 @@ async function handleApprove() {
|
||||
});
|
||||
approveDialogVisible.value = false;
|
||||
await fetchList();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message || 'Error');
|
||||
} catch (e: unknown) {
|
||||
showApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!rejectTarget.value) return;
|
||||
if (!rejectReason.value.trim()) {
|
||||
alert(t('deposit.reason_required'));
|
||||
ElMessage.warning(t('deposit.reason_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -112,8 +138,45 @@ async function handleReject() {
|
||||
});
|
||||
rejectDialogVisible.value = false;
|
||||
await fetchList();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message || 'Error');
|
||||
} catch (e: unknown) {
|
||||
showApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function canRevokeApproved(row: DepositOrderRow): boolean {
|
||||
if (row.status !== 'APPROVED' || !row.reviewedAt) return false;
|
||||
return Date.now() - new Date(row.reviewedAt).getTime() <= DEPOSIT_REVOKE_WINDOW_MS;
|
||||
}
|
||||
|
||||
async function handleReopen(row: DepositOrderRow) {
|
||||
if (!await confirmDepositAction(t('deposit.confirm_reopen'), t('deposit.reopen_review'))) return;
|
||||
try {
|
||||
await api.post(`/admin/deposit-orders/${row.id}/reopen`);
|
||||
await fetchList();
|
||||
} catch (e: unknown) {
|
||||
showApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(row: DepositOrderRow) {
|
||||
if (!await confirmDepositAction(t('deposit.confirm_revoke'), t('deposit.revoke'))) return;
|
||||
try {
|
||||
await api.post(`/admin/deposit-orders/${row.id}/reopen`);
|
||||
await fetchList();
|
||||
} catch (e: unknown) {
|
||||
showApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: DepositOrderRow) {
|
||||
if (!await confirmDepositAction(t('deposit.confirm_delete'), t('deposit.delete'))) return;
|
||||
try {
|
||||
await api.delete(`/admin/deposit-orders/${row.id}`);
|
||||
await fetchList();
|
||||
} catch (e: unknown) {
|
||||
showApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +265,21 @@ onMounted(fetchList);
|
||||
<template v-if="row.status === 'PENDING'">
|
||||
<button class="btn-sm btn-approve" @click="openApprove(row)">{{ t('deposit.approve') }}</button>
|
||||
<button class="btn-sm btn-reject" @click="openReject(row)">{{ t('deposit.reject') }}</button>
|
||||
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'APPROVED'">
|
||||
<button
|
||||
v-if="canRevokeApproved(row)"
|
||||
class="btn-sm btn-reopen"
|
||||
@click="handleRevoke(row)"
|
||||
>
|
||||
{{ t('deposit.revoke') }}
|
||||
</button>
|
||||
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'REJECTED'">
|
||||
<button class="btn-sm btn-reopen" @click="handleReopen(row)">{{ t('deposit.reopen_review') }}</button>
|
||||
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -218,6 +296,7 @@ onMounted(fetchList);
|
||||
<div v-if="approveDialogVisible" class="dialog-overlay" @click.self="approveDialogVisible = false">
|
||||
<div class="dialog-box">
|
||||
<h3>{{ t('deposit.approve_title') }}</h3>
|
||||
<p class="approve-check-hint">{{ t('deposit.approve_check_hint') }}</p>
|
||||
<div v-if="approveTarget" class="approve-content">
|
||||
<div class="info-row">
|
||||
<span>{{ t('deposit.player') }}:</span> <strong>{{ approveTarget.playerUsername }}</strong>
|
||||
@@ -305,6 +384,11 @@ onMounted(fetchList);
|
||||
.btn-approve:hover { background: rgba(61, 115, 88, 0.24); }
|
||||
.btn-reject { background: #3a1a1a; color: #f56c6c; }
|
||||
.btn-reject:hover { background: #4a2525; }
|
||||
.btn-reopen { background: #2a2410; color: #e6a23c; }
|
||||
.btn-reopen:hover { background: #3a3218; }
|
||||
.btn-delete { background: #2a2a2a; color: #aaa; }
|
||||
.btn-delete:hover { background: #3a3a3a; color: #f56c6c; }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
.pagination button { background: #333; color: #ddd; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
|
||||
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
||||
@@ -312,6 +396,16 @@ onMounted(fetchList);
|
||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 440px; max-width: 560px; }
|
||||
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
|
||||
.approve-check-hint {
|
||||
margin: -8px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(230, 162, 60, 0.12);
|
||||
border: 1px solid rgba(230, 162, 60, 0.35);
|
||||
color: #e6a23c;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.info-row { margin-bottom: 8px; font-size: 13px; }
|
||||
.info-row span { color: #888; margin-right: 4px; }
|
||||
.approve-screenshot { width: 200px; max-height: 200px; object-fit: contain; border-radius: 4px; cursor: pointer; margin-top: 4px; }
|
||||
|
||||
@@ -4,15 +4,17 @@ import { useRoute } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
|
||||
import MatchesSubNav from '../components/MatchesSubNav.vue';
|
||||
import CountryFlagSelect from '../components/outright/CountryFlagSelect.vue';
|
||||
import LogoUrlField from '../components/LogoUrlField.vue';
|
||||
import LeagueArchiveDialog from '../components/LeagueArchiveDialog.vue';
|
||||
import { getBuiltinCountry } from '../data/builtinCountries';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
MAX_EXPANDED_LEAGUES,
|
||||
} from '../utils/matchesListState';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
@@ -39,6 +41,9 @@ const leagueDialogMode = ref<'create' | 'edit'>('create');
|
||||
const leagueEditingId = ref('');
|
||||
const leagueForm = ref({ leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '', deleteOldLogo: false, originalLogoUrl: '' });
|
||||
const publishingLeagueId = ref('');
|
||||
const leagueArchiveVisible = ref(false);
|
||||
const leagueArchiveId = ref('');
|
||||
const leagueArchiveName = ref('');
|
||||
|
||||
const leagueDialogTitle = computed(() =>
|
||||
leagueDialogMode.value === 'edit'
|
||||
@@ -155,7 +160,18 @@ function openEditLeague(row: unknown) {
|
||||
async function toggleLeaguePublish(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
const id = String(r.id ?? '');
|
||||
if (leagueIsPublished(row)) return;
|
||||
const published = leagueIsPublished(row);
|
||||
if (published) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('league.confirm_unpublish'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('league.btn.unpublish'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
publishingLeagueId.value = id;
|
||||
try {
|
||||
await api.put(`/admin/leagues/${id}`, {
|
||||
@@ -163,9 +179,9 @@ async function toggleLeaguePublish(row: unknown) {
|
||||
leagueZh: String(r.leagueZh ?? ''),
|
||||
leagueMs: String(r.leagueMs ?? ''),
|
||||
logoUrl: String(r.logoUrl ?? '').trim() || undefined,
|
||||
isActive: true,
|
||||
isActive: !published,
|
||||
});
|
||||
ElMessage.success(t('msg.league_published'));
|
||||
ElMessage.success(published ? t('msg.league_unpublished') : t('msg.league_published'));
|
||||
await load({ keepExpand: true });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
@@ -255,7 +271,7 @@ async function submitCreate() {
|
||||
const lid = form.value.leagueId.trim();
|
||||
await load({ keepExpand: true });
|
||||
if (lid && !expandedRowKeys.value.includes(lid)) {
|
||||
expandedRowKeys.value = [...expandedRowKeys.value, lid];
|
||||
expandedRowKeys.value = capExpandedLeagueIds([...expandedRowKeys.value, lid]);
|
||||
persistListUiState();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -266,15 +282,24 @@ async function submitCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
function capExpandedLeagueIds(ids: string[]): string[] {
|
||||
return ids.slice(0, MAX_EXPANDED_LEAGUES);
|
||||
}
|
||||
|
||||
function onExpandChange(_row: unknown, expanded: unknown[]) {
|
||||
expandedRowKeys.value = expanded.map((r) => leagueId(r));
|
||||
expandedRowKeys.value = capExpandedLeagueIds(expanded.map((r) => leagueId(r)));
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
|
||||
const id = leagueId(row);
|
||||
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
|
||||
if (expandedRowKeys.value.includes(id)) {
|
||||
expandedRowKeys.value = expandedRowKeys.value.filter((k) => k !== id);
|
||||
} else {
|
||||
const next = [...expandedRowKeys.value, id];
|
||||
expandedRowKeys.value = capExpandedLeagueIds(next);
|
||||
}
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
@@ -339,6 +364,16 @@ function isLeagueExpanded(id: string) {
|
||||
return expandedRowKeys.value.includes(id);
|
||||
}
|
||||
|
||||
function openLeagueArchive(row: unknown) {
|
||||
leagueArchiveId.value = leagueId(row);
|
||||
leagueArchiveName.value = leagueTitle(row);
|
||||
leagueArchiveVisible.value = true;
|
||||
}
|
||||
|
||||
function onLeagueArchived() {
|
||||
void load();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -378,6 +413,7 @@ function isLeagueExpanded(id: string) {
|
||||
</div>
|
||||
|
||||
<section class="list-panel">
|
||||
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
:data="leagues"
|
||||
@@ -473,6 +509,18 @@ function isLeagueExpanded(id: string) {
|
||||
>
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
size="small"
|
||||
type="warning"
|
||||
:loading="publishingLeagueId === leagueId(row)"
|
||||
@click.stop="toggleLeaguePublish(row)"
|
||||
>
|
||||
{{ t('league.btn.unpublish') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click.stop="openLeagueArchive(row)">
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -609,6 +657,13 @@ function isLeagueExpanded(id: string) {
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<LeagueArchiveDialog
|
||||
v-model="leagueArchiveVisible"
|
||||
:league-id="leagueArchiveId"
|
||||
:league-name="leagueArchiveName"
|
||||
@archived="onLeagueArchived"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -780,4 +835,5 @@ function isLeagueExpanded(id: string) {
|
||||
:deep(.logo-url-field) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,6 @@ interface PaymentMethod {
|
||||
displayName: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
showOnPlayer: boolean;
|
||||
createdAt: string;
|
||||
translations?: {
|
||||
displayName?: Record<string, string>;
|
||||
@@ -42,7 +41,6 @@ const form = ref({
|
||||
displayName: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
showOnPlayer: true,
|
||||
translations: {
|
||||
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
||||
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
||||
@@ -76,7 +74,6 @@ function openCreate() {
|
||||
displayName: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
showOnPlayer: true,
|
||||
translations: {
|
||||
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
||||
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
||||
@@ -98,7 +95,6 @@ function openEdit(row: PaymentMethod) {
|
||||
displayName: row.displayName ?? '',
|
||||
sortOrder: row.sortOrder,
|
||||
isActive: row.isActive,
|
||||
showOnPlayer: row.showOnPlayer,
|
||||
translations: {
|
||||
displayName: {
|
||||
'zh-CN': t.displayName?.['zh-CN'] ?? '',
|
||||
@@ -153,9 +149,9 @@ async function handleDelete(row: PaymentMethod) {
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
async function toggleField(row: PaymentMethod, field: 'isActive' | 'showOnPlayer') {
|
||||
async function toggleActive(row: PaymentMethod) {
|
||||
try {
|
||||
await api.put(`/admin/payment-methods/${row.id}`, { [field]: !row[field] });
|
||||
await api.put(`/admin/payment-methods/${row.id}`, { isActive: !row.isActive });
|
||||
await fetchList();
|
||||
} catch { /* */ }
|
||||
}
|
||||
@@ -205,7 +201,6 @@ onMounted(fetchList);
|
||||
<th>{{ t('deposit.details') }}</th>
|
||||
<th>{{ t('deposit.sort') }}</th>
|
||||
<th>{{ t('deposit.active') }}</th>
|
||||
<th>{{ t('deposit.show_player') }}</th>
|
||||
<th>{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -227,17 +222,11 @@ onMounted(fetchList);
|
||||
<td>
|
||||
<span :class="row.isActive ? 'status-on' : 'status-off'">{{ row.isActive ? 'ON' : 'OFF' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="row.showOnPlayer ? 'status-on' : 'status-off'">{{ row.showOnPlayer ? 'ON' : 'OFF' }}</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-sm" @click="openEdit(row)">{{ t('common.edit') }}</button>
|
||||
<button class="btn-sm btn-toggle" @click="toggleField(row, 'isActive')">
|
||||
<button class="btn-sm btn-toggle" @click="toggleActive(row)">
|
||||
{{ row.isActive ? t('common.disable') : t('common.enable') }}
|
||||
</button>
|
||||
<button class="btn-sm btn-toggle" @click="toggleField(row, 'showOnPlayer')">
|
||||
{{ row.showOnPlayer ? t('common.hide_player') : t('common.show_player') }}
|
||||
</button>
|
||||
<button class="btn-sm btn-danger" @click="handleDelete(row)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -321,7 +310,6 @@ onMounted(fetchList);
|
||||
</div>
|
||||
<div class="form-group row-checks">
|
||||
<label><input type="checkbox" v-model="form.isActive" /> {{ t('deposit.active') }}</label>
|
||||
<label><input type="checkbox" v-model="form.showOnPlayer" /> {{ t('deposit.show_on_player') }}</label>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">{{ t('common.cancel') }}</button>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { VChart } from '../components/dashboard/echarts-setup';
|
||||
|
||||
const VChart = defineAsyncComponent(() =>
|
||||
import('../components/dashboard/echarts-setup').then((m) => m.VChart),
|
||||
);
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
buildBetTypePieOption,
|
||||
@@ -72,11 +75,22 @@ const loading = ref(false);
|
||||
const previewing = ref(false);
|
||||
const match = ref<AdminMatchDetail | null>(null);
|
||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||
const winnerTeamId = ref('');
|
||||
const outrightSelections = ref<
|
||||
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
|
||||
>([]);
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const resettlePreview = ref<Record<string, unknown> | null>(null);
|
||||
const resettleReason = ref('');
|
||||
const stats = ref<SettlementBetStats | null>(null);
|
||||
const statsSummary = ref<Pick<SettlementBetStats, 'summary' | 'bySelection'> | null>(null);
|
||||
const betsList = ref<SettlementBetStats['bets'] | null>(null);
|
||||
const statsLoading = ref(false);
|
||||
const betsLoading = ref(false);
|
||||
|
||||
const stats = computed((): SettlementBetStats | null => {
|
||||
if (!statsSummary.value || !betsList.value) return null;
|
||||
return { ...statsSummary.value, bets: betsList.value };
|
||||
});
|
||||
const betPage = ref(1);
|
||||
const betPageSize = ref(10);
|
||||
const previewPage = ref(1);
|
||||
@@ -85,6 +99,42 @@ const previewPageSize = ref(10);
|
||||
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score)
|
||||
|
||||
const matchId = computed(() => String(route.params.id ?? ''));
|
||||
const isOutright = computed(() => match.value?.isOutright === true);
|
||||
|
||||
const outrightTitle = computed(() => {
|
||||
const m = match.value;
|
||||
if (!m) return '';
|
||||
const name = m.matchName?.trim();
|
||||
if (name) return name;
|
||||
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
|
||||
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
|
||||
return m.leagueZh || m.leagueEn;
|
||||
});
|
||||
|
||||
const settlementPageTitle = computed(() =>
|
||||
isOutright.value ? t('settlement.outright.page_title') : t('page.settlement.title'),
|
||||
);
|
||||
|
||||
function outrightTeamLabel(row: { teamZh: string; teamEn: string; teamCode: string }) {
|
||||
const name =
|
||||
locale.value === 'en-US'
|
||||
? row.teamEn || row.teamZh
|
||||
: locale.value === 'ms-MY'
|
||||
? row.teamEn || row.teamZh
|
||||
: row.teamZh || row.teamEn;
|
||||
return `${name} (${row.teamCode})`;
|
||||
}
|
||||
|
||||
function buildSettlementPayload(): Record<string, unknown> | null {
|
||||
if (isOutright.value) {
|
||||
if (!winnerTeamId.value) {
|
||||
ElMessage.warning(t('settlement.outright.winner_required'));
|
||||
return null;
|
||||
}
|
||||
return { winnerTeamId: Number(winnerTeamId.value) };
|
||||
}
|
||||
return { ...score.value };
|
||||
}
|
||||
type PreviewItem = {
|
||||
betNo: string;
|
||||
betType: string;
|
||||
@@ -258,16 +308,12 @@ function matchBetSelectionSummary(
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
async function loadStatsSummary() {
|
||||
if (!matchId.value) return;
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
|
||||
params: { page: betPage.value, pageSize: betPageSize.value },
|
||||
});
|
||||
stats.value = data.data as SettlementBetStats;
|
||||
betPage.value = stats.value.bets.page;
|
||||
betPageSize.value = stats.value.bets.pageSize;
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/summary`);
|
||||
statsSummary.value = data.data as Pick<SettlementBetStats, 'summary' | 'bySelection'>;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
@@ -276,15 +322,39 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBets() {
|
||||
if (!matchId.value) return;
|
||||
betsLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/bets`, {
|
||||
params: { page: betPage.value, pageSize: betPageSize.value },
|
||||
});
|
||||
const payload = data.data as SettlementBetStats['bets'];
|
||||
betsList.value = payload;
|
||||
betPage.value = payload.page;
|
||||
betPageSize.value = payload.pageSize;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
betsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
betPage.value = 1;
|
||||
await Promise.all([loadStatsSummary(), loadBets()]);
|
||||
}
|
||||
|
||||
function onBetPageChange(page: number) {
|
||||
betPage.value = page;
|
||||
void loadStats();
|
||||
void loadBets();
|
||||
}
|
||||
|
||||
function onBetPageSizeChange(size: number) {
|
||||
betPageSize.value = size;
|
||||
betPage.value = 1;
|
||||
void loadStats();
|
||||
void loadBets();
|
||||
}
|
||||
|
||||
async function loadMatch() {
|
||||
@@ -292,26 +362,49 @@ async function loadMatch() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail & {
|
||||
score?: { htHome: number; htAway: number; ftHome: number; ftAway: number } | null;
|
||||
};
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/matches');
|
||||
return;
|
||||
}
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
const settleable =
|
||||
detail.status === 'CLOSED' ||
|
||||
detail.status === 'PENDING_SETTLEMENT' ||
|
||||
detail.status === 'SETTLED';
|
||||
if (!settleable) {
|
||||
ElMessage.warning(t('settlement.must_close_first'));
|
||||
router.replace('/matches');
|
||||
router.replace(detail.isOutright ? '/matches/outrights' : '/matches');
|
||||
return;
|
||||
}
|
||||
match.value = detail;
|
||||
if (detail.score) {
|
||||
score.value = { ...detail.score };
|
||||
score.value = {
|
||||
htHome: detail.score.htHome,
|
||||
htAway: detail.score.htAway,
|
||||
ftHome: detail.score.ftHome,
|
||||
ftAway: detail.score.ftAway,
|
||||
};
|
||||
winnerTeamId.value = detail.score.winnerTeamId ?? '';
|
||||
} else {
|
||||
winnerTeamId.value = '';
|
||||
}
|
||||
if (detail.isOutright) {
|
||||
const outrightRes = await api.get(`/admin/outrights/${matchId.value}`);
|
||||
const payload = outrightRes.data.data as {
|
||||
selections: Array<{
|
||||
teamId: string | null;
|
||||
teamCode: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
outrightSelections.value = (payload.selections ?? [])
|
||||
.filter((s) => s.teamId)
|
||||
.map((s) => ({
|
||||
teamId: s.teamId as string,
|
||||
teamCode: s.teamCode,
|
||||
teamZh: s.teamZh,
|
||||
teamEn: s.teamEn,
|
||||
}));
|
||||
} else {
|
||||
outrightSelections.value = [];
|
||||
}
|
||||
betPage.value = 1;
|
||||
await loadStats();
|
||||
@@ -326,8 +419,10 @@ async function loadMatch() {
|
||||
const isSettled = computed(() => match.value?.status === 'SETTLED');
|
||||
|
||||
async function previewResettlement() {
|
||||
const payload = buildSettlementPayload();
|
||||
if (!payload) return;
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
|
||||
...score.value,
|
||||
...payload,
|
||||
reason: resettleReason.value.trim() || undefined,
|
||||
});
|
||||
resettlePreview.value = data.data;
|
||||
@@ -347,6 +442,9 @@ function settlementApiError(e: unknown, fallback: string) {
|
||||
if (raw === 'Score not recorded' || raw === 'Score not found') {
|
||||
return t('settlement.err_score_not_recorded');
|
||||
}
|
||||
if (raw === 'SETTLEMENT_WINNER_REQUIRED') {
|
||||
return t('settlement.outright.winner_required');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -368,12 +466,14 @@ async function loadPreviewItems(page = previewPage.value, pageSize = previewPage
|
||||
}
|
||||
|
||||
async function previewSettlement() {
|
||||
const payload = buildSettlementPayload();
|
||||
if (!payload) return;
|
||||
preview.value = null;
|
||||
previewPage.value = 1;
|
||||
previewing.value = true;
|
||||
try {
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
|
||||
...score.value,
|
||||
...payload,
|
||||
page: 1,
|
||||
pageSize: previewPageSize.value,
|
||||
});
|
||||
@@ -416,7 +516,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div v-loading="loading" class="settlement-page">
|
||||
<AdminSubNav
|
||||
:title="t('page.settlement.title')"
|
||||
:title="settlementPageTitle"
|
||||
:subtitle="`#${matchId}`"
|
||||
>
|
||||
<template #extra>
|
||||
@@ -426,7 +526,13 @@ onMounted(() => {
|
||||
|
||||
<el-card v-if="match" class="settle-top-card" shadow="never">
|
||||
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
|
||||
<div class="match-inline">
|
||||
<div v-if="isOutright" class="outright-settle-head">
|
||||
<p class="outright-settle-title">{{ outrightTitle }}</p>
|
||||
<span class="kickoff-inline">
|
||||
<span class="meta-k">{{ t('settlement.outright.title') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="match-inline">
|
||||
<div class="team-chip">
|
||||
<img
|
||||
v-if="match.homeTeamLogoUrl"
|
||||
@@ -455,7 +561,24 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="settle-score-row">
|
||||
<div class="score-inline-group">
|
||||
<div v-if="isOutright" class="outright-winner-row">
|
||||
<span class="score-title">{{ t('settlement.outright.winner') }}</span>
|
||||
<el-select
|
||||
v-model="winnerTeamId"
|
||||
filterable
|
||||
clearable
|
||||
class="outright-winner-select"
|
||||
:placeholder="t('settlement.outright.winner_ph')"
|
||||
>
|
||||
<el-option
|
||||
v-for="row in outrightSelections"
|
||||
:key="row.teamId"
|
||||
:label="outrightTeamLabel(row)"
|
||||
:value="row.teamId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-else class="score-inline-group">
|
||||
<div class="score-block compact">
|
||||
<span class="score-title">{{ t('settlement.ht_score') }}</span>
|
||||
<div class="score-inputs">
|
||||
@@ -483,7 +606,9 @@ onMounted(() => {
|
||||
>
|
||||
{{ t('settlement.preview_btn') }}
|
||||
</el-button>
|
||||
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
|
||||
<span class="preview-hint">{{
|
||||
isOutright ? t('settlement.outright.preview_hint') : t('settlement.preview_hint')
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input
|
||||
@@ -638,7 +763,7 @@ onMounted(() => {
|
||||
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
|
||||
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<div v-loading="betsLoading" class="table-wrap">
|
||||
<el-table
|
||||
v-if="stats.bets.items.length"
|
||||
:data="stats.bets.items"
|
||||
@@ -751,6 +876,32 @@ onMounted(() => {
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.outright-settle-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.outright-settle-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.outright-winner-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 240px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.outright-winner-select {
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.settle-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -235,7 +235,7 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table :key="locale" :data="filteredResults" stripe row-key="id">
|
||||
<el-table :data="filteredResults" stripe row-key="id">
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MatchCreateForm {
|
||||
awayTeamEn: string;
|
||||
awayTeamMs: string;
|
||||
isHot: boolean;
|
||||
correctScoreEnabled: boolean;
|
||||
displayOrder: number;
|
||||
matchName: string;
|
||||
stage: string;
|
||||
@@ -48,6 +49,7 @@ export function emptyMatchForm(): MatchCreateForm {
|
||||
awayTeamEn: '',
|
||||
awayTeamMs: '',
|
||||
isHot: false,
|
||||
correctScoreEnabled: true,
|
||||
displayOrder: 0,
|
||||
matchName: '',
|
||||
stage: '',
|
||||
@@ -81,6 +83,7 @@ export type AdminMatchDetail = {
|
||||
status: string;
|
||||
isOutright: boolean;
|
||||
isHot: boolean;
|
||||
correctScoreEnabled: boolean;
|
||||
displayOrder: number;
|
||||
startTime: string;
|
||||
leagueId?: string;
|
||||
@@ -107,6 +110,7 @@ export type AdminMatchDetail = {
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
winnerTeamId?: string | null;
|
||||
} | null;
|
||||
markets?: AdminMarket[];
|
||||
};
|
||||
@@ -143,6 +147,7 @@ export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
awayTeamEn: d.awayTeamEn,
|
||||
awayTeamMs: d.awayTeamMs ?? '',
|
||||
isHot: d.isHot,
|
||||
correctScoreEnabled: d.correctScoreEnabled ?? true,
|
||||
displayOrder: d.displayOrder ?? 0,
|
||||
matchName: d.matchName ?? '',
|
||||
stage: d.stage ?? '',
|
||||
@@ -239,6 +244,7 @@ export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
correctScoreEnabled: form.correctScoreEnabled,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
@@ -284,6 +290,7 @@ export function buildMatchUpdatePayload(form: MatchCreateForm) {
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
correctScoreEnabled: form.correctScoreEnabled,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
|
||||
@@ -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