feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View File

@@ -46,6 +46,14 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminRowActionsDropdown from '../components/AdminRowActionsDropdown.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
import {
fetchAdminAgentCreditContext,
@@ -53,6 +61,8 @@ import {
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const inviteDialogOpen = ref(false);
/* ─── Tier-1 agent list ─── */
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
@@ -75,6 +85,10 @@ const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
function setSubAgentTableRef(level: number, el: unknown) {
subAgentTableRefs.value[level] = el as { toggleRowExpansion: (row: AgentRow) => void } | null;
}
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
@@ -189,7 +203,8 @@ const bettingLimits = ref({
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const hierarchySettings = ref({ maxAgentLevel: 0 });
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
const freezeAgentTarget = ref<AgentRow | null>(null);
@@ -199,6 +214,9 @@ const freezeAgentForm = ref({
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('');
@@ -296,7 +314,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number):
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, hierarchySettings.value.defaultSubAgentCreditRatio ?? 50);
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
@@ -370,6 +388,7 @@ onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadHierarchySettings();
loadPlatformDirectSettings();
loadResetDatabaseStatus();
loadAgentOptions();
loadAllPlayers();
@@ -453,6 +472,14 @@ function onSubAgentSizeChange(level: number, size: number) {
loadSubAgentsAtLevel(level);
}
function bindSubAgentPageChange(level: number) {
return (p: number) => onSubAgentPageChange(level, p);
}
function bindSubAgentSizeChange(level: number) {
return (size: number) => onSubAgentSizeChange(level, size);
}
function searchSubAgentsAtLevel(level: number) {
ensureSubAgentState(level).page = 1;
loadSubAgentsAtLevel(level);
@@ -537,8 +564,14 @@ function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent
}
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
const tableRef = { value: subAgentTableRefs.value[level] };
onAgentRowClick(row, tableRef, _column, event);
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRefs.value[level]?.toggleRowExpansion(row);
}
function bindSubAgentRowClick(level: number) {
return (row: AgentRow, column: unknown, event: MouseEvent) => {
onSubAgentRowClick(level, row, column, event);
};
}
watch(activeViewTab, (tab) => {
@@ -682,9 +715,9 @@ async function savePlayerSettings() {
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = data.data;
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
} catch {
hierarchySettings.value = { maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 };
hierarchySettings.value = { maxAgentLevel: 0 };
}
}
@@ -692,7 +725,7 @@ async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = data.data;
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 } } };
@@ -703,6 +736,38 @@ async function saveHierarchySettings() {
}
}
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);
@@ -792,7 +857,7 @@ async function submitCreate() {
phone: createForm.value.phone.trim() || undefined,
email: createForm.value.email.trim() || undefined,
creditLimit: createForm.value.creditLimit,
cashbackRate: createForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(createForm.value.cashbackRate),
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
};
@@ -857,12 +922,16 @@ async function submitEditPlayer() {
editPlayerLoading.value = true;
try {
const newPwd = editPlayerForm.value.newPassword.trim();
const { data } = await api.put(`/admin/users/${editingId.value}`, {
const payload: Record<string, unknown> = {
username: editPlayerForm.value.username.trim(),
phone: editPlayerForm.value.phone.trim() || undefined,
email: editPlayerForm.value.email.trim() || undefined,
password: newPwd || undefined,
});
cashbackRate: editPlayerForm.value.useCustomCashback
? percentToDecimalRate(editPlayerForm.value.customCashbackRate ?? 0)
: null,
};
const { data } = await api.put(`/admin/users/${editingId.value}`, payload);
const updated = data.data as PlayerDetail;
if (newPwd) {
editPlayerForm.value.managedPassword = updated.managedPassword ?? newPwd;
@@ -904,7 +973,7 @@ async function submitEditAgent() {
status: editAgentForm.value.status,
phone: editAgentForm.value.phone.trim() || undefined,
email: editAgentForm.value.email.trim() || undefined,
cashbackRate: editAgentForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(editAgentForm.value.cashbackRate),
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
password: newPwd || undefined,
@@ -1178,22 +1247,27 @@ function creditTypeLabel(type: string) {
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="5"
controls-position="right"
:disabled="hierarchySaving"
/>
<span class="list-settings-unit">%</span>
</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">
@@ -1236,7 +1310,13 @@ function creditTypeLabel(type: string) {
</el-collapse-item>
</el-collapse>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="mgr-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs mgr-top-tabs--with-invite">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
@@ -1297,6 +1377,12 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
<template #default="{ row }">
<el-tooltip :content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`" placement="top">
@@ -1310,32 +1396,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="108">
<el-table-column :label="t('common.actions')" min-width="360" align="center" fixed="right">
<template #default="{ row }">
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" width="108">
<template #default="{ row }">
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
<span>{{ formatLastLogin(row.createdAt) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="420" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="row"
@detail="openDetailPlayer(row.id)"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@edit="openEditPlayer(row.id)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreezePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -1408,6 +1479,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<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('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1426,24 +1503,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="100">
<el-table-column :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row: player }">
<el-tooltip v-if="player.lastLoginAt" :content="formatTime(player.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(player.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
: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)"
/>
</template>
</el-table-column>
</el-table>
@@ -1472,18 +1542,22 @@ function creditTypeLabel(type: string) {
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" min-width="72" align="center" />
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1545,14 +1619,14 @@ function creditTypeLabel(type: string) {
</div>
<div class="table-wrap">
<el-table
:ref="(el) => { subAgentTableRefs[agentLevel] = el as { toggleRowExpansion: (row: AgentRow) => void } | null }"
:ref="(el: unknown) => setSubAgentTableRef(agentLevel, el)"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="(row, column, event) => onSubAgentRowClick(agentLevel, row, column, event)"
@row-click="bindSubAgentRowClick(agentLevel)"
>
<template #empty>
<AdminTableEmpty />
@@ -1570,6 +1644,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<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('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1580,14 +1660,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column :label="t('common.actions')" min-width="280" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
</div>
<AdminPlayerRowActions
: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)"
/>
</template>
</el-table-column>
</el-table>
@@ -1613,16 +1696,20 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1635,13 +1722,14 @@ function creditTypeLabel(type: string) {
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="(p) => onSubAgentPageChange(agentLevel, p)"
@size-change="(size) => onSubAgentSizeChange(agentLevel, size)"
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</section>
</el-tab-pane>
</el-tabs>
</div>
<!-- DIALOGS -->
@@ -1798,14 +1886,7 @@ function creditTypeLabel(type: string) {
<div v-else class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number
v-model="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="createForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
@@ -1875,6 +1956,20 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<p v-else class="field-hint block-hint">
{{ t('cashback.use_default_rate', { rate: `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }) }}
</p>
</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
@@ -1928,7 +2023,7 @@ function creditTypeLabel(type: string) {
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="editAgentForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
<RatePercentInput v-model="editAgentForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="editAgentForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
@@ -2026,38 +2121,46 @@ function creditTypeLabel(type: string) {
</el-dialog>
<!-- Player Detail -->
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="playerDetail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item :label="t('common.col_id')">{{ playerDetail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ playerDetail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.current_password')">{{ playerDetail.managedPassword ?? '—' }}</el-descriptions-item>
<el-descriptions-item v-if="!playerDetail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('common.col_id')">{{ playerDetail.id }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.username')">{{ playerDetail.username }}</AdminDetailItem>
<AdminDetailItem :label="t('common.status')">
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('agent.field.cashback_rate')">
{{
playerDetail.customCashbackRate != null
? formatRatePercent(playerDetail.customCashbackRate)
: t('cashback.use_default_rate', { rate: formatRatePercent(playerDetail.defaultCashbackRate) })
}}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.availableBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">
{{ formatAmount(playerDetail.frozenBalance) }}
<span v-if="shouldCompact(playerDetail.frozenBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.frozenBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')">
{{ playerDetail.lastLoginAt ? formatTime(playerDetail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(playerDetail.createdAt) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
{{ playerDetail.managedPassword ?? '—' }}
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
@@ -2092,7 +2195,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ agentDetail.childAgentCount }} {{ t('common.people') }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.player_liability')">{{ formatAmount(agentDetail.directPlayerLiability) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">{{ formatAmount(agentDetail.childAgentExposure) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ agentDetail.cashbackRate }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(agentDetail.cashbackRate) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ agentDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ agentDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
@@ -2101,6 +2204,8 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<InviteCodePanel :invite-code="agentDetail.inviteCode" compact readonly class="detail-invite" />
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
@@ -2147,18 +2252,57 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tabs__content) {
.mgr-tabs-shell {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tab-pane) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 108px;
}
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 96px;
height: 38px;
padding: 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(64, 158, 255, 0.28);
}
.agent-mgr-page > .mgr-tabs-shell {
flex: 1;
min-height: 0;
}
.mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tab-pane) {
flex: 1;
min-height: 0;
display: flex;
@@ -2195,14 +2339,21 @@ function creditTypeLabel(type: string) {
.list-panel-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
.list-panel-toolbar .list-chrome__grow {
flex: 1 1 280px;
min-width: 0;
flex-wrap: wrap;
row-gap: 8px;
}
.list-panel-toolbar .list-chrome__actions {
flex-shrink: 0;
}
@@ -2293,7 +2444,7 @@ function creditTypeLabel(type: string) {
font-weight: 700;
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--green-bright);
background-color: var(--gold-bright);
height: 2px;
}
.inner-tabs :deep(.el-tabs__content) {
@@ -2309,13 +2460,29 @@ function creditTypeLabel(type: string) {
font-size: 13px;
font-weight: 600;
}
.action-btns {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
.player-list-panel .table-wrap :deep(.el-table),
.agent-list-panel .table-wrap :deep(.el-table) {
min-width: 880px;
}
.expand-panel .inner-table {
width: 100%;
}
.expand-panel :deep(.el-table__body-wrapper) {
overflow-x: auto;
}
@media (max-width: 900px) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Inherited from old pages ─── */
@@ -2388,8 +2555,15 @@ function creditTypeLabel(type: string) {
grid-template-columns: 1fr 1fr;
gap: 0 10px;
}
.c-green { color: #2fb56a; }
.c-green { color: var(--gold-text); }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--el-color-primary);
}
.affiliation-tag {
max-width: 100%;
overflow: hidden;
@@ -2464,22 +2638,3 @@ function creditTypeLabel(type: string) {
gap: 8px;
}
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.agent-mgr-page .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.agent-mgr-page .el-button.is-link.el-button--warning:hover,
.agent-mgr-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>