feat(admin+api): 列表页子路由重构、结算历史与充值截图清理

赛事/代理管理从表格展开改为子路由详情页;结算页增加预览弹窗与历史记录;媒体库支持截图存储统计与自动/手动清理;Player 端充值截图压缩为 WebP 300KB。
This commit is contained in:
2026-06-22 12:28:25 +08:00
parent 499522f3fb
commit 648c314e23
38 changed files with 3298 additions and 1147 deletions

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import { ref, onMounted, onActivated, computed, watch, reactive, h } from 'vue';
import { ref, onMounted, onActivated, computed, watch, reactive, h, provide } from 'vue';
defineOptions({ name: 'AdminAgentManager' });
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api';
import { clearStaffSession } from '../stores/auth';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
const { t, localeTag } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const { hasPermission, role: staffRole } = usePermissions();
@@ -66,9 +65,9 @@ import {
} from '../utils/format-amount';
import { formatAgentLevelNumeral } from '../utils/agent-level-label';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../utils/expandable-table';
agentDirectPlayersReloadKey,
agentPlayerActionsKey,
} from '../composables/agent-direct-players-context';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
@@ -113,6 +112,7 @@ type SubAgentLevelState = {
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const hierarchySettings = ref({ maxAgentLevel: 0 });
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
@@ -176,12 +176,12 @@ const playerFilterAgent = ref('');
const playerLoading = ref(false);
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Expansion state ─── */
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const directPlayersReload = ref<(() => void) | null>(null);
provide(agentDirectPlayersReloadKey, directPlayersReload);
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const isAgentChildRoute = computed(() =>
/^\/users\/agents\/[^/]+\/players/.test(route.path) || route.path === '/users/settings',
);
const createToolbarChildLevel = ref<number | null>(null);
@@ -217,19 +217,9 @@ const creditForm = ref({ amount: 10000, remark: '' });
const creditContext = ref<AgentCreditAdjustContext | null>(null);
const creditContextLoading = ref(false);
/* ─── Global settings ─── */
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySettings = ref({ maxAgentLevel: 0 });
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
const pageInitLoaded = ref(false);
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
@@ -239,18 +229,7 @@ const freezeAgentForm = ref({
blockDirectPlayerLogin: false,
unfreezeDirectPlayers: false,
});
const hierarchySaving = ref(false);
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const platformDirectSaving = ref(false);
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');
@@ -413,11 +392,8 @@ function resolveCreateParentLabel(agentId: string) {
return agentId;
}
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
function ensurePageInit(): Promise<void> {
if (settingsLoaded.value) return Promise.resolve();
if (pageInitLoaded.value) return Promise.resolve();
if (!pageInitPromise) {
pageInitPromise = loadUsersPageInit().finally(() => {
pageInitPromise = null;
@@ -444,10 +420,12 @@ function loadActiveViewTabData() {
}
onMounted(() => {
void ensurePageInit();
loadActiveViewTabData();
});
// KeepAlive 激活时静默刷新当前 tab 列表(不重复 page-init
onActivated(() => {
void ensurePageInit();
const tab = activeViewTab.value;
if (tab === 'players' && canViewUsers.value && allPlayers.value.length > 0) void loadAllPlayers();
else if (tab === 'tier1Agents' && canViewAgents.value && tier1Agents.value.length > 0) void loadTier1Agents();
@@ -465,44 +443,29 @@ 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;
if (payload.agentLevelCounts[1] !== undefined) {
tier1Total.value = payload.agentLevelCounts[1];
}
}
settingsLoaded.value = true;
pageInitLoaded.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();
}
});
function openGlobalSettings() {
void router.push('/users/settings');
}
/* ─── Load tier-1 agents ─── */
async function loadTier1Agents() {
@@ -550,6 +513,9 @@ async function loadAgentLevelCounts() {
normalized[Number(lvl)] = Number(cnt) || 0;
}
agentLevelCounts.value = normalized;
if (normalized[1] !== undefined) {
tier1Total.value = normalized[1];
}
} catch {
agentLevelCounts.value = {};
}
@@ -683,16 +649,28 @@ function affiliationLabel(row: Pick<PlayerRow, 'affiliationAgents'>) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
function eventClickElement(event: Event): Element | null {
const target = event.target;
if (target instanceof Element) return target;
if (target instanceof Node) return target.parentElement;
return null;
}
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
function openAgentDirectPlayers(row: AgentRow, _column: unknown, event: Event) {
const el = eventClickElement(event);
if (!el) return;
if (el.closest('button') || el.closest('.el-button') || el.closest('.admin-agent-row-actions')) return;
void router.push({
path: `/users/agents/${row.userId}/players`,
query: {
username: row.username,
fromTab: activeViewTab.value,
},
});
}
function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
function agentRowClassName() {
return 'row-navigable';
}
watch(activeViewTab, (tab) => {
@@ -716,199 +694,6 @@ watch(visibleSubAgentTabLevels, (levels) => {
}
});
/* ─── Expansion ─── */
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onAgentRowClick(row: AgentRow, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
const userId = row.userId;
const next = new Set(expandedSet.value);
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(agentId: string) {
expandLoading.value[agentId] = true;
try {
const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } });
agentPlayersMap.value[agentId] = data.data.items as PlayerRow[];
} catch {
agentPlayersMap.value[agentId] = [];
} finally {
expandLoading.value[agentId] = false;
}
}
function getPlayers(agentId: string) {
return agentPlayersMap.value[agentId] || [];
}
function refreshExpandedAgentPlayers() {
for (const agentId of expandedSet.value) {
loadExpansionData(agentId);
}
}
/* ─── Global settings ─── */
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
async function loadBettingLimits() {
try {
const { data } = await api.get('/admin/settings/betting-limits');
bettingLimits.value = data.data;
} catch {
/* defaults */
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadBettingLimits();
} finally {
limitsSaving.value = false;
}
}
async function loadPlayerSettings() {
try {
const { data } = await api.get('/admin/users/settings/account');
playerSettings.value = data.data;
} catch {
/* defaults */
}
}
async function savePlayerSettings() {
settingsSaving.value = true;
try {
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
playerSettings.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadPlayerSettings();
} finally {
settingsSaving.value = false;
}
}
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
} catch {
hierarchySettings.value = { maxAgentLevel: 0 };
}
}
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadHierarchySettings();
} finally {
hierarchySaving.value = false;
}
}
async function loadPlatformDirectSettings() {
try {
const { data } = await api.get('/admin/settings/cashback/platform-direct');
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
} catch {
platformDirectRate.value = 0;
adminInviteRate.value = 0;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadPlatformDirectSettings();
} finally {
platformDirectSaving.value = false;
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
@@ -1030,7 +815,7 @@ async function submitCreate() {
}
const parentId = createParentAgentId.value || createForm.value.parentId;
if (parentId) {
await loadExpansionData(parentId);
directPlayersReload.value?.();
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -1374,7 +1159,7 @@ async function submitFreezeAgent() {
function refreshExpandedParents() {
loadAllPlayers();
reloadAgentLists();
refreshExpandedAgentPlayers();
directPlayersReload.value?.();
}
const {
@@ -1445,107 +1230,40 @@ function creditTypeLabel(type: string) {
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
return type;
}
provide(agentPlayerActionsKey, {
get canCreatePlayer() {
return canCreateUsers.value;
},
get playerActionFlags() {
return playerActionFlags.value;
},
openCreatePlayer,
openDetailPlayer,
openEditPlayer,
openTransfer,
toggleFreezePlayer,
deletePlayer,
openPlayerWalletLedger,
});
</script>
<template>
<div class="admin-list-page agent-mgr-page">
<!-- Global settings collapse -->
<el-collapse v-if="canManageSettings" v-model="settingsCollapseOpen" class="list-settings">
<el-collapse-item :title="t('user.page_settings')" name="settings">
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.hierarchy.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.hierarchy.max_level')">
<el-input-number
v-model="hierarchySettings.maxAgentLevel"
:min="0"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert v-if="!resetAllowed" type="warning" :closable="false" show-icon class="reset-db-alert" :title="t('user.reset_database_disabled_prod')" />
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input v-model="resetConfirmPhrase" :placeholder="t('user.reset_database_confirm_ph')" style="width: 160px" :disabled="!resetAllowed" autocomplete="off" />
</el-form-item>
<el-form-item>
<el-button type="danger" plain :loading="resetLoading" :disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'" @click="resetDatabase">{{ t('user.reset_database_btn') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<div class="agent-mgr-shell">
<router-view v-if="isAgentChildRoute" />
<div v-else class="admin-list-page agent-mgr-page">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="mgr-tabs-shell">
<el-button v-if="canManageSettings" type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs" :class="{ 'mgr-top-tabs--with-invite': canManageSettings }">
<div v-if="canManageSettings" class="mgr-toolbar-actions">
<el-button class="settings-toolbar-btn" @click="openGlobalSettings">
{{ t('user.page_settings') }}
</el-button>
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
</div>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs" :class="{ 'mgr-top-tabs--with-actions': canManageSettings }">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane v-if="canViewUsers" :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
@@ -1685,81 +1403,20 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
<p class="list-hint">{{ t('agent.open_agent_hint') }}</p>
<AdminTableWrap>
<el-table
:data="tier1Agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onTier1AgentRowClick"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<!-- Built-in expand column -->
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" min-width="120">
<template #default="{ row: player }">
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row: player }">
<AdminPlayerRowActions
v-bind="playerActionFlags"
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
</el-table-column>
<el-table-column type="index" :index="(i: number) => (tier1Page - 1) * tier1PageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('common.status')" min-width="72">
@@ -1854,69 +1511,19 @@ function creditTypeLabel(type: string) {
</el-button>
</div>
</div>
<p class="list-hint">{{ t('agent.open_agent_hint') }}</p>
<AdminTableWrap>
<el-table
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onSubAgentRowClick"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">{{ t('common.loading') }}</div>
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" min-width="120">
<template #default="{ row: player }">
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="280" align="center">
<template #default="{ row: player }">
<AdminPlayerRowActions
v-bind="playerActionFlags"
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
</el-table-column>
<el-table-column type="index" :index="(i: number) => (ensureSubAgentState(agentLevel).page - 1) * ensureSubAgentState(agentLevel).pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
@@ -1968,6 +1575,7 @@ function creditTypeLabel(type: string) {
</template>
</el-tabs>
</div>
</div>
<!-- DIALOGS -->
@@ -2534,6 +2142,23 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.agent-mgr-shell > :deep(.agent-direct-players-page),
.agent-mgr-shell > :deep(.global-settings-page) {
flex: 1;
min-height: 0;
}
.compact-agent-table :deep(.row-navigable) {
cursor: pointer;
}
.mgr-tabs-shell {
position: relative;
flex: 1;
@@ -2542,15 +2167,31 @@ function creditTypeLabel(type: string) {
flex-direction: column;
}
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 108px;
.mgr-top-tabs--with-actions :deep(.el-tabs__header) {
padding-right: 248px;
}
.invite-prominent-btn {
.mgr-toolbar-actions {
position: absolute;
right: 0;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.settings-toolbar-btn {
min-width: 96px;
height: 38px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
border-radius: 8px;
}
.invite-prominent-btn {
position: static;
min-width: 96px;
height: 38px;
padding: 0 22px;
@@ -2756,11 +2397,11 @@ function creditTypeLabel(type: string) {
}
@media (max-width: 900px) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
.mgr-top-tabs--with-actions :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
.mgr-toolbar-actions {
position: static;
align-self: flex-end;
margin: 0 0 8px;