Files
thebet365/apps/admin/src/views/AgentManager.vue
Mars 9972e4e7d1 sync(admin): 从 main 同步列表布局、滚动固定与侧栏选中
checkout main: App.vue, ManageLayout, AgentManager, Matches, LeagueMatches*, AgentDirectPlayersView
2026-06-22 16:52:30 +08:00

2561 lines
93 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onActivated, computed, watch, reactive, h, provide } from 'vue';
defineOptions({ name: 'AdminAgentManager' });
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 { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
const { t, localeTag } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const { hasPermission, role: staffRole } = usePermissions();
const canViewAgents = computed(() => hasPermission(AdminPerm.agentsView));
const canViewUsers = computed(() => hasPermission(AdminPerm.usersView));
const canCreateUsers = computed(() => hasPermission(AdminPerm.usersCreate));
const canCreateAgents = computed(() => hasPermission(AdminPerm.agentsCreate));
const canResetPassword = computed(() => hasPermission(AdminPerm.usersResetPassword));
const canDeposit = computed(() => hasPermission(AdminPerm.walletDeposit));
const canWithdraw = computed(() => hasPermission(AdminPerm.walletWithdraw));
const canAdjustCredit = computed(() => hasPermission(AdminPerm.agentsCredit));
const canManageSettings = computed(() => hasPermission(AdminPerm.settings));
const canEditPlayerProfile = computed(() => canCreateUsers.value);
const playerActionsReadOnly = computed(
() => canViewUsers.value && !canCreateUsers.value && !canDeposit.value && !canWithdraw.value,
);
const playerActionFlags = computed(() => ({
showEdit: canViewUsers.value && (canEditPlayerProfile.value || canResetPassword.value),
showDeposit: canDeposit.value,
showWithdraw: canWithdraw.value,
showFreeze: canEditPlayerProfile.value,
showDelete: canEditPlayerProfile.value,
}));
import {
emptyPlayerCreateForm,
emptyPlayerEditForm,
editFormFromDetail,
buildCreatePlayerPayload,
type PlayerRow,
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
formatPlayerAffiliationLabel,
assertPlayerUsername,
} from './user-form';
import {
emptyAgentEditForm,
editFormFromAgentDetail,
type AgentRow,
type AgentDetail,
type AgentEditForm,
} from './agent-form';
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
type DisplayAgentRow = AgentRow;
import {
formatAmount,
formatAmountFull,
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import { formatAgentLevelNumeral } from '../utils/agent-level-label';
import {
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';
import InitialDepositRemarkField from '../components/InitialDepositRemarkField.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 AdminTableWrap from '../components/AdminTableWrap.vue';
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
import AdminAgentRowActions from '../components/AdminAgentRowActions.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,
maxCreditIncreaseAmount,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const inviteDialogOpen = ref(false);
/* ─── Tier-1 agent list ─── */
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
const tier1Page = ref(1);
const tier1PageSize = ref(10);
const tier1Keyword = ref('');
const tier1FilterStatus = ref('');
/* ─── Sub-agent lists by level (L2, L3, …) ─── */
type SubAgentLevelState = {
agents: AgentRow[];
total: number;
page: number;
pageSize: number;
keyword: string;
filterStatus: string;
};
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]) {
subAgentLevelState[level] = {
agents: [],
total: 0,
page: 1,
pageSize: 10,
keyword: '',
filterStatus: '',
};
}
return subAgentLevelState[level];
}
function agentLevelTabName(level: number) {
return `agentLevel-${level}`;
}
function lvlLabel(level: number) {
return formatAgentLevelNumeral(level, localeTag.value);
}
function agentTierName(level: number) {
return t('agent.level_name', { level: lvlLabel(level) });
}
function agentLevelTabLabel(level: number) {
const count = agentLevelCounts.value[level] ?? 0;
return `${agentTierName(level)} (${count})`;
}
const visibleSubAgentTabLevels = computed(() => {
const counts = agentLevelCounts.value;
const max = hierarchySettings.value.maxAgentLevel;
const levels = new Set<number>([2]);
if (max === 0) {
for (const [lvl, cnt] of Object.entries(counts)) {
const n = Number(lvl);
if (n >= 3 && cnt > 0) levels.add(n);
}
} else {
for (let n = 3; n <= max; n++) {
if ((counts[n] ?? 0) > 0) levels.add(n);
}
}
return [...levels].sort((a, b) => a - b);
});
/* ─── View tab: players | tier1Agents | agentLevel-N ─── */
const activeViewTab = ref('players');
/* ─── All players list ─── */
const allPlayers = ref<PlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(10);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
const playerLoading = ref(false);
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
const directPlayersReload = ref<(() => void) | null>(null);
provide(agentDirectPlayersReloadKey, directPlayersReload);
const isAgentChildRoute = computed(() =>
/^\/users\/agents\/[^/]+\/players/.test(route.path) || route.path === '/users/settings',
);
const createToolbarChildLevel = ref<number | null>(null);
/* ─── Dialogs ─── */
const createVisible = ref(false);
const editPlayerVisible = ref(false);
const editAgentVisible = ref(false);
const detailPlayerVisible = ref(false);
const detailAgentVisible = ref(false);
const creditVisible = ref(false);
const createLoading = ref(false);
const editPlayerLoading = ref(false);
const editAgentLoading = ref(false);
const creditLoading = ref(false);
/* ─── Create form (unified) ─── */
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
const createParentAgentId = ref('');
const createParentLocked = ref(false);
const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent
/* ─── Edit forms ─── */
const editPlayerForm = ref<PlayerEditForm>(emptyPlayerEditForm());
const editAgentForm = ref<AgentEditForm>(emptyAgentEditForm());
const editingId = ref('');
/* ─── Detail ─── */
const playerDetail = ref<PlayerDetail | null>(null);
const agentDetail = ref<AgentDetail | null>(null);
/* ─── Credit ─── */
const creditForm = ref({ amount: 10000, remark: '' });
const creditContext = ref<AgentCreditAdjustContext | null>(null);
const creditContextLoading = ref(false);
/* ─── 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);
const freezeAgentTarget = ref<AgentRow | null>(null);
const freezeAgentForm = ref({
freezeDirectPlayers: false,
blockDirectPlayerLogin: false,
unfreezeDirectPlayers: false,
});
const agentOptionsLoading = ref(false);
const createDialogTitle = computed(() => {
if (createAccountMode.value === 1) return t('agent.dialog.create');
if (createAccountMode.value === 2) {
const lvl = createTargetAgentLevel.value;
if (lvl === 2) return t('agent_portal.create_sub_agent_dialog');
if (lvl != null) return t('agent.dialog.create_level_agent', { level: lvlLabel(lvl) });
return t('agent.dialog.create_child_agent');
}
return t('user.dialog.create');
});
const createSubAgentLevelPreview = computed(() => {
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
return parentLevel != null ? parentLevel + 1 : null;
});
const createTargetAgentLevel = computed(() => {
if (createAccountMode.value !== 2) return null;
if (createToolbarChildLevel.value != null) return createToolbarChildLevel.value;
return createSubAgentLevelPreview.value;
});
function resolveParentAgentLevel(agentId: string): number | null {
const opt = agentOptions.value.find((a) => a.id === agentId);
if (opt) return opt.level;
const row = findAgentRowByUserId(agentId);
if (row) return row.level;
return null;
}
function parentOptionsForChildLevel(childLevel: number) {
if (childLevel < 2) return [];
const requiredParentLevel = childLevel - 1;
return parentAgentOptionsForCreate.value.filter((a) => a.level === requiredParentLevel);
}
function childAgentActionLabel(parentLevel: number) {
return t('agent.create_level_agent', { level: lvlLabel(parentLevel + 1) });
}
const createParentCreditCache = ref<Record<string, string>>({});
function findAgentRowByUserId(userId: string): AgentRow | undefined {
const tier1 = tier1Agents.value.find((a) => a.userId === userId);
if (tier1) return tier1;
for (const lvl of visibleSubAgentTabLevels.value) {
const hit = subAgentLevelState[lvl]?.agents.find((a) => a.userId === userId);
if (hit) return hit;
}
return undefined;
}
async function ensureCreateParentCredit(agentId: string) {
const hit = findAgentRowByUserId(agentId);
if (hit) {
createParentCreditCache.value[agentId] = hit.availableCredit;
return;
}
if (createParentCreditCache.value[agentId]) return;
try {
const { data } = await api.get(`/admin/agents/${agentId}`);
createParentCreditCache.value[agentId] = String(data.data.availableCredit ?? '0');
} catch {
createParentCreditCache.value[agentId] = '0';
}
}
const createParentAvailableCredit = computed(() => {
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
const id = createParentAgentId.value;
const hit = findAgentRowByUserId(id);
if (hit) return hit.availableCredit;
return createParentCreditCache.value[id] ?? null;
});
const createSubCreditMax = computed(() => {
const n = Number(createParentAvailableCredit.value ?? 0);
return Number.isFinite(n) ? Math.max(0, n) : 0;
});
function computeSubAgentCreditByRatio(available: number, ratioPercent: number): number {
if (available <= 0) return 0;
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
const raw = available * pct;
const rounded = pct >= 1 ? available : Math.floor(raw / 100) * 100;
return Math.min(available, Math.max(0, rounded));
}
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
createForm.value.creditLimit = computeSubAgentCreditByRatio(createSubCreditMax.value, ratioPercent);
}
function applyCreateSubAgentDefaultCredit() {
if (createAccountMode.value !== 2) return;
createForm.value.creditLimit = computeDefaultSubAgentCreditLimit(createSubCreditMax.value);
}
watch(
() => [createVisible.value, createAccountMode.value, createParentAgentId.value] as const,
([visible, mode, parentId]) => {
if (!visible || mode !== 2 || !parentId) return;
void ensureCreateParentCredit(parentId).then(() => {
applyCreateSubAgentDefaultCredit();
});
},
);
function canAgentCreateSub(row: Pick<AgentRow, 'level'>) {
const max = hierarchySettings.value.maxAgentLevel;
if (max === 0) return true;
return row.level < max;
}
function parentChainLabel(row: Pick<AgentRow, 'parentChainLabel' | 'parentUsername'>) {
if (row.parentChainLabel) return row.parentChainLabel;
return row.parentUsername ?? '—';
}
const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1));
const parentAgentOptionsForCreate = computed(() => {
const max = hierarchySettings.value.maxAgentLevel;
return agentOptions.value.filter((a) => max === 0 || a.level < max);
});
const createParentSelectOptions = computed(() => {
const childLevel = createToolbarChildLevel.value;
if (childLevel == null || childLevel < 2) return [];
return parentOptionsForChildLevel(childLevel);
});
const createParentSelectPlaceholder = computed(() => {
const childLevel = createToolbarChildLevel.value;
if (childLevel == null || childLevel < 2) return t('user.filter.agent_ph');
return t('agent.hint.select_parent_for_level', { level: lvlLabel(childLevel - 1) });
});
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
return `L${a.level} ${chain} (#${a.id})`;
}
function resolveCreateParentLabel(agentId: string) {
const opt = agentOptions.value.find((a) => a.id === agentId);
if (opt) return agentOptionLabel(opt);
const tier1 = tier1Agents.value.find((a) => a.userId === agentId);
if (tier1) return tier1.username;
const row = findAgentRowByUserId(agentId);
if (row) {
return row.parentUsername ? `${row.parentUsername} / ${row.username}` : row.username;
}
return agentId;
}
function ensurePageInit(): Promise<void> {
if (pageInitLoaded.value) return Promise.resolve();
if (!pageInitPromise) {
pageInitPromise = loadUsersPageInit().finally(() => {
pageInitPromise = null;
});
}
return pageInitPromise;
}
function loadActiveViewTabData() {
const tab = activeViewTab.value;
if (tab === 'players') {
if (canViewUsers.value) void loadAllPlayers();
return;
}
if (!canViewAgents.value) return;
if (tab === 'tier1Agents') {
void loadTier1Agents();
return;
}
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
}
}
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();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
async function loadUsersPageInit() {
try {
const { data } = await api.get('/admin/users/page-init');
const payload = data.data as {
hierarchySettings?: { maxAgentLevel: number };
agentLevelCounts?: Record<number, number>;
};
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
};
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
if (payload.agentLevelCounts[1] !== undefined) {
tier1Total.value = payload.agentLevelCounts[1];
}
}
pageInitLoaded.value = true;
} catch {
/* keep defaults */
}
}
function openGlobalSettings() {
void router.push('/users/settings');
}
/* ─── Load tier-1 agents ─── */
async function loadTier1Agents() {
try {
const { data } = await api.get('/admin/agents', {
params: {
page: tier1Page.value,
pageSize: tier1PageSize.value,
keyword: tier1Keyword.value.trim() || undefined,
status: tier1FilterStatus.value || undefined,
level: 1,
},
});
tier1Agents.value = data.data.items as AgentRow[];
tier1Total.value = data.data.total;
} catch (e) {
tier1Agents.value = [];
tier1Total.value = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onTier1PageChange(p: number) {
tier1Page.value = p;
loadTier1Agents();
}
function onTier1SizeChange(size: number) {
tier1PageSize.value = size;
tier1Page.value = 1;
loadTier1Agents();
}
function searchTier1Agents() {
tier1Page.value = 1;
loadTier1Agents();
}
async function loadAgentLevelCounts() {
try {
const { data } = await api.get('/admin/agents/level-counts');
const raw = data.data ?? {};
const normalized: Record<number, number> = {};
for (const [lvl, cnt] of Object.entries(raw)) {
normalized[Number(lvl)] = Number(cnt) || 0;
}
agentLevelCounts.value = normalized;
if (normalized[1] !== undefined) {
tier1Total.value = normalized[1];
}
} catch {
agentLevelCounts.value = {};
}
}
async function loadSubAgentsAtLevel(level: number) {
const st = ensureSubAgentState(level);
try {
const { data } = await api.get('/admin/agents', {
params: {
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
} catch (e) {
st.agents = [];
st.total = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onSubAgentPageChange(level: number, p: number) {
ensureSubAgentState(level).page = p;
loadSubAgentsAtLevel(level);
}
function onSubAgentSizeChange(level: number, size: number) {
const st = ensureSubAgentState(level);
st.pageSize = size;
st.page = 1;
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);
}
function reloadSubAgentTabs() {
return loadAgentLevelCounts().then(() => {
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
});
}
function reloadAgentLists() {
loadTier1Agents();
void reloadSubAgentTabs();
}
/* ─── Load main agent list ─── */
async function load() {
reloadAgentLists();
}
async function loadAgentOptions(keyword = '') {
agentOptionsLoading.value = true;
try {
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 {
const params: Record<string, unknown> = {
page: playerPage.value,
pageSize: playerPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
status: playerFilterStatus.value || undefined,
};
if (playerFilterAgent.value) {
params.parentId = playerFilterAgent.value;
}
const { data } = await api.get('/admin/users', { params });
allPlayers.value = data.data.items as PlayerRow[];
playerTotal.value = data.data.total;
} catch {
allPlayers.value = [];
playerTotal.value = 0;
} finally {
playerLoading.value = false;
}
}
function searchPlayers() {
playerPage.value = 1;
loadAllPlayers();
}
function onPlayerPageChange(p: number) {
playerPage.value = p;
loadAllPlayers();
}
function onPlayerSizeChange(size: number) {
playerPageSize.value = size;
playerPage.value = 1;
loadAllPlayers();
}
function affiliationLabel(row: Pick<PlayerRow, 'affiliationAgents'>) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
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 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 agentRowClassName() {
return 'row-navigable';
}
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
return;
}
if (tab === 'players' && canViewUsers.value && !allPlayers.value.length) void loadAllPlayers();
if (tab === 'tier1Agents' && canViewAgents.value && !tier1Agents.value.length) void loadTier1Agents();
});
watch(visibleSubAgentTabLevels, (levels) => {
const tab = activeViewTab.value;
const m = /^agentLevel-(\d+)$/.exec(tab);
if (!m) return;
const activeLevel = Number(m[1]);
if (levels.includes(activeLevel)) {
const st = subAgentLevelState[activeLevel];
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
}
});
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
walletLedgerPlayerId.value = playerId;
walletLedgerPlayerUsername.value = playerUsername ?? null;
walletLedgerVisible.value = true;
}
/* ─── Create (unified) ─── */
function openCreateAccount() {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = '';
createParentLocked.value = false;
createAccountMode.value = 0;
createVisible.value = true;
}
function openCreateTier1Agent() {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = true;
createParentAgentId.value = '';
createParentLocked.value = false;
createAccountMode.value = 1;
createVisible.value = true;
}
function openCreatePlayer(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createForm.value.parentId = parentAgentUserId;
createParentAgentId.value = parentAgentUserId;
createParentLocked.value = true;
createAccountMode.value = 0;
createVisible.value = true;
}
function openCreateSubAgent(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = parentAgentUserId;
createParentLocked.value = true;
const parentLevel = resolveParentAgentLevel(parentAgentUserId);
createToolbarChildLevel.value = parentLevel != null ? parentLevel + 1 : null;
createAccountMode.value = 2;
createVisible.value = true;
}
function openCreateSubAgentFromToolbar(childLevel: number) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentLocked.value = false;
createToolbarChildLevel.value = childLevel;
const parents = parentOptionsForChildLevel(childLevel);
createParentAgentId.value = parents.length === 1 ? parents[0].id : '';
createAccountMode.value = 2;
createVisible.value = true;
}
async function submitCreate() {
const isSubAgent = createAccountMode.value === 2;
const isAgent = createForm.value.asTier1Agent || isSubAgent;
let payload: Record<string, unknown>;
try {
if (isSubAgent) {
if (!createParentAgentId.value) {
throw new Error(t('agent.hint.select_parent_for_level', { level: lvlLabel((createToolbarChildLevel.value ?? 2) - 1) }));
}
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
const targetLevel = createTargetAgentLevel.value;
if (targetLevel == null || parentLevel == null || parentLevel !== targetLevel - 1) {
throw new Error(t('agent.err.parent_level_mismatch', { level: lvlLabel(targetLevel ?? 0) }));
}
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
if (createForm.value.creditLimit > createSubCreditMax.value) {
throw new Error(t('err.insufficient_credit'));
}
payload = {
username: createForm.value.username.trim(),
password: createForm.value.password,
asSubAgent: true,
parentAgentId: createParentAgentId.value,
phone: createForm.value.phone.trim() || undefined,
email: createForm.value.email.trim() || undefined,
creditLimit: createForm.value.creditLimit,
cashbackRate: percentToDecimalRate(createForm.value.cashbackRate),
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
};
} else {
payload = buildCreatePlayerPayload(createForm.value);
// When creating player from expansion, ensure parentId is set
if (createParentAgentId.value && !payload.parentId) {
payload.parentId = createParentAgentId.value;
}
}
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createLoading.value = true;
try {
await api.post('/admin/users', payload);
ElMessage.success(
isAgent
? t('msg.agent_created')
: t('user.msg.created_with_password', { password: createForm.value.password }),
);
createVisible.value = false;
const createdLevel = isSubAgent ? createTargetAgentLevel.value : null;
load();
refreshExpandedParents();
if (createdLevel != null && createdLevel >= 2) {
activeViewTab.value = agentLevelTabName(createdLevel);
}
const parentId = createParentAgentId.value || createForm.value.parentId;
if (parentId) {
directPlayersReload.value?.();
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
}
/* ─── Edit Player ─── */
async function openEditPlayer(id: string) {
const { data } = await api.get(`/admin/users/${id}`);
const d = data.data as PlayerDetail;
editingId.value = id;
editPlayerForm.value = editFormFromDetail(d);
editPlayerVisible.value = true;
}
async function submitEditPlayer() {
if (editPlayerForm.value.newPassword && editPlayerForm.value.newPassword.length < 8) {
ElMessage.warning(t('err.password_min'));
return;
}
const newPwd = editPlayerForm.value.newPassword.trim();
const passwordOnly =
newPwd &&
!canEditPlayerProfile.value &&
canResetPassword.value &&
!editPlayerForm.value.useCustomCashback;
if (passwordOnly) {
editPlayerLoading.value = true;
try {
const { data } = await api.post(`/admin/users/${editingId.value}/reset-password`, {
password: newPwd,
});
const plain = data.data?.password ?? newPwd;
editPlayerForm.value.managedPassword = plain;
editPlayerForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: plain }));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editPlayerLoading.value = false;
}
return;
}
if (!canEditPlayerProfile.value) {
ElMessage.warning(t('msg.save_failed'));
return;
}
try {
assertPlayerUsername(editPlayerForm.value.username);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editPlayerLoading.value = true;
try {
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;
editPlayerForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editPlayerForm.value.managedPassword }));
return;
}
ElMessage.success(t('msg.saved'));
editPlayerVisible.value = false;
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editPlayerLoading.value = false;
}
}
/* ─── Edit Agent ─── */
async function openEditAgent(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
const d = data.data as AgentDetail;
editingId.value = userId;
editAgentForm.value = editFormFromAgentDetail(d);
editAgentVisible.value = true;
}
async function submitEditAgent() {
if (editAgentForm.value.newPassword && editAgentForm.value.newPassword.length < 8) {
ElMessage.warning(t('err.password_min'));
return;
}
editAgentLoading.value = true;
try {
const newPwd = editAgentForm.value.newPassword.trim();
const { data } = await api.put(`/admin/agents/${editingId.value}`, {
username: editAgentForm.value.username.trim(),
status: editAgentForm.value.status,
phone: editAgentForm.value.phone.trim() || undefined,
email: editAgentForm.value.email.trim() || undefined,
cashbackRate: percentToDecimalRate(editAgentForm.value.cashbackRate),
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
password: newPwd || undefined,
});
const updated = data.data as AgentDetail;
if (newPwd) {
editAgentForm.value.managedPassword = updated.managedPassword ?? newPwd;
editAgentForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editAgentForm.value.managedPassword }));
return;
}
ElMessage.success(t('msg.saved'));
editAgentVisible.value = false;
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editAgentLoading.value = false;
}
}
/* ─── Detail ─── */
async function openDetailPlayer(id: string) {
const { data } = await api.get(`/admin/users/${id}`);
playerDetail.value = data.data as PlayerDetail;
detailPlayerVisible.value = true;
}
async function openDetailAgent(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
agentDetail.value = data.data as AgentDetail;
detailAgentVisible.value = true;
}
/* ─── Credit ─── */
async function openCredit(userId: string) {
editingId.value = userId;
creditForm.value = { amount: 10000, remark: '' };
creditContext.value = null;
creditVisible.value = true;
creditContextLoading.value = true;
try {
creditContext.value = await fetchAdminAgentCreditContext(userId);
} catch (e: unknown) {
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
creditVisible.value = false;
} finally {
creditContextLoading.value = false;
}
}
async function submitCredit() {
if (creditForm.value.amount === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
const maxInc = maxCreditIncreaseAmount(creditContext.value);
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/admin/agents/${editingId.value}/credit`, {
amount: creditForm.value.amount,
requestId: `credit-${editingId.value}-${Date.now()}`,
remark: creditForm.value.remark || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
load();
refreshExpandedParents();
} catch (e: unknown) {
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
}
/* ─── Freeze / Unfreeze ─── */
async function toggleFreezePlayer(row: PlayerRow) {
const freeze = row.status === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
t('msg.freeze_confirm_body', {
action,
name: row.username,
extra: freeze ? t('msg.freeze_extra') : '',
}),
t('msg.freeze_confirm_title', { action }),
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch {
return;
}
try {
await api.put(`/admin/users/${row.id}`, {
status: freeze ? 'SUSPENDED' : 'ACTIVE',
});
ElMessage.success(t('msg.freeze_done', { action }));
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── 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;
return subAgentAccountStatus(freezeAgentTarget.value) === 'ACTIVE';
});
function toggleFreezeAgent(row: AgentRow) {
freezeAgentTarget.value = row;
freezeAgentForm.value = {
freezeDirectPlayers: false,
blockDirectPlayerLogin: false,
unfreezeDirectPlayers: false,
};
freezeAgentVisible.value = true;
}
async function submitFreezeAgent() {
const row = freezeAgentTarget.value;
if (!row) return;
const suspend = freezeAgentIsSuspend.value;
const action = suspend ? t('common.freeze') : t('common.unfreeze');
freezeAgentLoading.value = true;
try {
if (suspend) {
await api.put(`/admin/agents/${row.userId}`, {
status: 'SUSPENDED',
freezeDirectPlayers: freezeAgentForm.value.freezeDirectPlayers,
blockDirectPlayerLogin: freezeAgentForm.value.blockDirectPlayerLogin,
});
ElMessage.success(
freezeAgentForm.value.freezeDirectPlayers
? t('agent.msg.cascade_freeze_done')
: t('agent.msg.freeze_done', { action }),
);
} else {
await api.put(`/admin/agents/${row.userId}`, {
status: 'ACTIVE',
unfreezeDirectPlayers: freezeAgentForm.value.unfreezeDirectPlayers,
});
ElMessage.success(
freezeAgentForm.value.unfreezeDirectPlayers
? t('agent.msg.cascade_unfreeze_done')
: t('agent.msg.freeze_done', { action }),
);
}
freezeAgentVisible.value = false;
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
} finally {
freezeAgentLoading.value = false;
}
}
/* ─── Helpers ─── */
function refreshExpandedParents() {
loadAllPlayers();
reloadAgentLists();
directPlayersReload.value?.();
}
const {
transferVisible,
transferLoading,
transferType,
transferTarget,
transferAmount,
transferRemark,
transferContext,
transferContextLoading,
transferAmountRange,
transferAmountDisabled,
transferAmountExceedsCap,
transferAmountCapError,
transferTitle,
openTransfer,
submitTransfer,
} = useAdminPlayerTransfer(async () => {
load();
refreshExpandedParents();
});
function creditLine(row: AgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
function formatTime(v: string) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value);
}
function formatLastLogin(v: string | null) {
if (!v) return t('common.never_login');
const d = new Date(v);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
function statusLabel(s: string) {
const key = `user.status.${s}`;
const v = t(key);
return v !== key ? v : s;
}
function creditTypeLabel(type: string) {
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
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="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">
<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">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="playerKeyword"
:placeholder="t('user.filter.username_ph')"
clearable
style="width: 180px"
@keyup.enter="searchPlayers"
/>
</el-form-item>
<el-form-item v-if="canViewAgents" :label="t('user.filter.agent')">
<el-select
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"
:key="a.id"
:label="agentOptionLabel(a)"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="playerFilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchPlayers">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div v-if="canCreateUsers" class="list-chrome__actions">
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
</div>
</div>
<AdminTableWrap>
<el-table v-loading="playerLoading" :data="allPlayers" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (playerPage - 1) * playerPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" min-width="128">
<template #default="{ row }">
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.agent')" min-width="200">
<template #default="{ row }">
<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">
<span class="amount-compact">{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
<template #default="{ row }">
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="340" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-bind="playerActionFlags"
: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)"
@delete="deletePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="playerPage"
v-model:page-size="playerPageSize"
:total="playerTotal"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPlayerPageChange"
@size-change="onPlayerSizeChange"
/>
</div>
</section>
</el-tab-pane>
<!-- Tab: 一级代理 -->
<el-tab-pane v-if="canViewAgents" :label="`${agentTierName(1)} (${tier1Total})`" name="tier1Agents">
<section class="list-panel agent-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input v-model="tier1Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier1Agents" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="tier1FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchTier1Agents">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div v-if="canCreateAgents" class="list-chrome__actions">
<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"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<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">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="level" :label="t('agent.col.level')" min-width="52" align="center">
<template #default="{ row }">L{{ row.level }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="148" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<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 }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminAgentRowActions
:row="{ userId: row.userId, status: row.status }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="tier1Page"
v-model:page-size="tier1PageSize"
:total="tier1Total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onTier1PageChange"
@size-change="onTier1SizeChange"
/>
</div>
</section>
</el-tab-pane>
<!-- Tab: L2+ 各级代理 -->
<template v-if="canViewAgents">
<el-tab-pane
v-for="agentLevel in visibleSubAgentTabLevels"
:key="agentLevel"
:label="agentLevelTabLabel(agentLevel)"
:name="agentLevelTabName(agentLevel)"
>
<section class="list-panel agent-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="ensureSubAgentState(agentLevel).keyword"
:placeholder="t('agent.filter.username_ph')"
clearable
style="width: 180px"
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select
v-model="ensureSubAgentState(agentLevel).filterStatus"
:placeholder="t('common.all')"
clearable
style="width: 120px"
>
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreateSubAgentFromToolbar(agentLevel)">
{{ agentLevel === 2 ? t('agent.create_sub_btn') : t('agent.create_level_agent_btn', { level: lvlLabel(agentLevel) }) }}
</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"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<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>
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" min-width="72">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="148" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</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')" min-width="300" align="center">
<template #default="{ row }">
<AdminAgentRowActions
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
:current-page="ensureSubAgentState(agentLevel).page"
:page-size="ensureSubAgentState(agentLevel).pageSize"
:total="ensureSubAgentState(agentLevel).total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</section>
</el-tab-pane>
</template>
</el-tabs>
</div>
</div>
<!-- DIALOGS -->
<!-- Freeze / Unfreeze Agent -->
<el-dialog
v-model="freezeAgentVisible"
:title="freezeAgentIsSuspend ? t('agent.freeze.confirm_freeze_title') : t('agent.unfreeze.confirm_title')"
width="480px"
destroy-on-close
>
<template v-if="freezeAgentTarget">
<p class="freeze-agent-intro">
{{
freezeAgentIsSuspend
? t('agent.freeze.confirm_freeze_body', { name: freezeAgentTarget.username })
: t('agent.freeze.confirm_unfreeze_body', { name: freezeAgentTarget.username })
}}
</p>
<div v-if="freezeAgentIsSuspend" class="freeze-agent-options">
<el-checkbox
v-if="freezeAgentTarget.directPlayerCount > 0"
v-model="freezeAgentForm.freezeDirectPlayers"
>
{{ t('agent.freeze.opt_freeze_direct_players') }}
</el-checkbox>
<el-checkbox v-model="freezeAgentForm.blockDirectPlayerLogin">
{{ t('agent.freeze.opt_block_player_login') }}
</el-checkbox>
</div>
<div v-else class="freeze-agent-options">
<el-checkbox
v-if="freezeAgentTarget.directPlayerCount > 0"
v-model="freezeAgentForm.unfreezeDirectPlayers"
>
{{ t('agent.unfreeze.opt_unfreeze_direct_players') }}
</el-checkbox>
</div>
</template>
<template #footer>
<el-button @click="freezeAgentVisible = false">{{ t('common.cancel') }}</el-button>
<el-button
:type="freezeAgentIsSuspend ? 'warning' : 'primary'"
:loading="freezeAgentLoading"
@click="submitFreezeAgent"
>
{{ freezeAgentIsSuspend ? t('common.freeze') : t('common.unfreeze') }}
</el-button>
</template>
</el-dialog>
<!-- Create (unified) -->
<el-dialog
v-model="createVisible"
:title="createDialogTitle"
width="460px"
align-center
destroy-on-close
class="create-account-dialog"
>
<div
v-if="createAccountMode === 2 && createParentAgentId && createParentLocked"
class="create-meta-bar"
>
<div class="create-meta-row">
<span class="create-meta-label">{{ t('agent.field.parent_agent') }}</span>
<span class="create-meta-value">{{ resolveCreateParentLabel(createParentAgentId) }}</span>
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
</div>
<div class="create-meta-row">
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
</div>
</div>
<el-form label-width="100px" size="small" class="compact-create-form">
<template v-if="createAccountMode === 2 && !createParentLocked">
<el-form-item :label="t('agent.field.parent_agent')" required>
<el-select
v-model="createParentAgentId"
:placeholder="createParentSelectPlaceholder"
style="width: 100%"
>
<el-option
v-for="a in createParentSelectOptions"
:key="a.id"
:label="agentOptionLabel(a)"
:value="a.id"
/>
</el-select>
<div v-if="createTargetAgentLevel" class="field-hint">
{{ t('agent.hierarchy.create_level_hint', { n: lvlLabel(createTargetAgentLevel) }) }}
· {{ t('agent.hint.select_parent_for_level', { level: lvlLabel(createTargetAgentLevel - 1) }) }}
</div>
</el-form-item>
<div v-if="createParentAgentId" class="create-meta-bar create-meta-bar--inline">
<div class="create-meta-row">
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
</div>
</div>
</template>
<el-form-item :label="t('user.col.username')" required>
<el-input
v-model="createForm.username"
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
/>
</el-form-item>
<div class="create-form-pair">
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
</div>
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
</el-form-item>
<el-form-item v-if="createAccountMode === 0 && !createParentLocked" :label="t('user.filter.agent')">
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
</el-select>
</el-form-item>
<template v-if="createAccountMode === 1 || createAccountMode === 2">
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number
v-model="createForm.creditLimit"
:min="0"
:max="createAccountMode === 2 ? createSubCreditMax : undefined"
:step="1000"
style="width: 100%"
/>
<div v-if="createAccountMode === 2 && createParentAgentId" class="credit-quick-row">
<div class="field-hint">
{{ t('agent.hierarchy.create_credit_quick_hint', { amount: formatAmount(createParentAvailableCredit ?? '0') }) }}
</div>
<div class="credit-quick-btns">
<el-button
v-for="ratio in creditQuickRatios"
:key="ratio"
size="small"
:disabled="createSubCreditMax <= 0"
@click="applyCreateSubAgentCreditRatio(ratio)"
>
{{ ratio }}%
</el-button>
</div>
</div>
<div v-else class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<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%" />
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
</el-form-item>
</template>
<template v-if="createAccountMode === 0">
<div class="create-form-pair">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
</el-form-item>
<el-form-item
v-if="createForm.initialDeposit > 0"
:label="t('user.field.initial_deposit_kind')"
>
<InitialDepositRemarkField
v-model:kind="createForm.initialDepositRemarkKind"
v-model:custom="createForm.initialDepositRemarkCustom"
operator="admin"
/>
</el-form-item>
</template>
<template v-else-if="createAccountMode === 1">
<div class="create-form-pair">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</div>
</template>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- Edit Player -->
<el-dialog
v-model="editPlayerVisible"
:title="canEditPlayerProfile ? t('user.dialog.edit') : t('user.field.reset_password')"
width="560px"
destroy-on-close
class="user-edit-dialog"
>
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editPlayerForm.id }}</span>
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.basic_info') }}</div>
<el-form-item :label="t('user.col.username')">
<el-input
v-model="editPlayerForm.username"
:readonly="!canEditPlayerProfile"
:placeholder="t('user.ph.username_player')"
/>
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</div>
<div v-if="!canEditPlayerProfile" class="edit-form-section">
<div class="section-title">{{ t('user.section.affiliation') }}</div>
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
</div>
<div class="edit-form-section">
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
<span v-if="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</div>
<div v-if="canEditPlayerProfile" class="edit-form-section">
<div class="section-title">{{ t('user.section.affiliation') }}</div>
<el-form-item :label="t('user.col.agent')">
<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"
/>
<span v-else class="field-hint inline-hint">
{{ `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }}
</span>
</div>
</el-form-item>
</div>
<div v-if="canEditPlayerProfile" class="edit-form-section">
<div class="section-title">{{ t('user.section.contact') }}</div>
<el-row :gutter="12" class="contact-row">
<el-col :span="12">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="edit-form-section edit-stats-panel">
<div class="section-title">{{ t('user.section.account_overview') }}</div>
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editPlayerVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editPlayerLoading" @click="submitEditPlayer">
{{ canEditPlayerProfile ? t('user.btn.save_profile') : t('user.field.reset_password') }}
</el-button>
</template>
</el-dialog>
<!-- Edit Agent -->
<el-dialog v-model="editAgentVisible" :title="t('agent.dialog.edit')" width="520px" destroy-on-close class="agent-edit-dialog">
<el-form label-width="88px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editAgentForm.id }}</span>
<el-tag :type="statusTagType(editAgentForm.status)" size="small">{{ statusLabel(editAgentForm.status) }}</el-tag>
<el-tag size="small" type="info">L{{ editAgentForm.level }}</el-tag>
<span v-if="editAgentForm.parentUsername" class="text-muted">{{ t('user.col.agent') }}: {{ editAgentForm.parentUsername }}</span>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editAgentForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editAgentForm.managedPassword" class="password-plain">{{ editAgentForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editAgentForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editAgentForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.account_status')">
<el-radio-group v-model="editAgentForm.status">
<el-radio value="ACTIVE">{{ t('user.status.ACTIVE') }}</el-radio>
<el-radio value="SUSPENDED">{{ t('user.status.SUSPENDED') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<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%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="editAgentForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editAgentForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editAgentForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ formatAmount(editAgentForm.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editAgentForm.availableCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">{{ formatAmount(editAgentForm.usedCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editAgentForm.directPlayerCount }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ editAgentForm.childAgentCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editAgentForm.lastLoginAt ? formatTime(editAgentForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editAgentForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editAgentVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editAgentLoading" @click="submitEditAgent">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- Player transfer (deposit / withdraw) -->
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
<WalletTransferContext
:context="transferContext"
:mode="transferType"
:loading="transferContextLoading"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min"
:disabled="transferAmountDisabled"
:step="10"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="transferRemark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
<el-button
type="primary"
:loading="transferLoading"
:disabled="transferAmountDisabled || transferAmountExceedsCap"
@click="submitTransfer"
>
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
<!-- Credit -->
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="520px" destroy-on-close>
<AgentCreditContext
:context="creditContext"
:loading="creditContextLoading"
:adjust-amount="creditForm.amount"
/>
<el-form label-width="88px">
<el-form-item :label="t('agent.field.agent_id')">
<el-input :model-value="editingId" disabled />
</el-form-item>
<el-form-item :label="t('agent.field.adjust_amount')">
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCredit">{{ t('agent.btn.confirm_adjust') }}</el-button>
</template>
</el-dialog>
<!-- Player Detail -->
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="playerDetail">
<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>
</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')">
{{ formatRatePercent(playerDetail.customCashbackRate ?? 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>
</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>
</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') }}
</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>
</template>
</el-dialog>
<!-- Agent Detail -->
<el-dialog v-model="detailAgentVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
<template v-if="agentDetail">
<el-descriptions :column="2" border size="small" class="detail-block">
<el-descriptions-item :label="t('common.col_id')">{{ agentDetail.userId }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ agentDetail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<el-tag :type="statusTagType(agentDetail.status)" size="small">{{ statusLabel(agentDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.level')">L{{ agentDetail.level }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.credit_limit')">
{{ formatAmount(agentDetail.creditLimit) }}
<span v-if="shouldCompact(agentDetail.creditLimit)" class="amount-full-hint">{{ formatAmountFull(agentDetail.creditLimit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">
{{ formatAmount(agentDetail.usedCredit) }}
<span v-if="shouldCompact(agentDetail.usedCredit)" class="amount-full-hint">{{ formatAmountFull(agentDetail.usedCredit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">
{{ formatAmount(agentDetail.availableCredit) }}
<span v-if="shouldCompact(agentDetail.availableCredit)" class="amount-full-hint">{{ formatAmountFull(agentDetail.availableCredit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ agentDetail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
<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')">{{ 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">
{{ agentDetail.lastLoginAt ? formatTime(agentDetail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<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
v-if="agentDetail"
link
type="primary"
@click="router.push({ path: '/finance-logs', query: { tab: 'credit', agentId: agentDetail.userId } })"
>
{{ t('agent.credit_tx.view_all') }}
</el-button>
</div>
<el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')">
<el-table-column :label="t('agent.col.credit_type')" width="80">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span>{{ formatAmount(row.amount) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
<span>{{ formatAmount(row.creditAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
<el-table-column :label="t('audit.col.time')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</el-dialog>
<PlayerWalletLedgerDialog
v-model="walletLedgerVisible"
:player-id="walletLedgerPlayerId"
:player-username="walletLedgerPlayerUsername"
/>
</div>
</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;
min-height: 0;
display: flex;
flex-direction: column;
}
.mgr-top-tabs--with-actions :deep(.el-tabs__header) {
padding-right: 248px;
}
.mgr-toolbar-actions {
position: absolute;
right: 16px;
top: 14px;
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;
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;
flex-direction: column;
}
.player-list-panel,
.agent-list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mgr-top-tabs :deep(.el-tabs__item) {
font-size: 14px;
font-weight: 700;
}
.freeze-agent-intro {
margin: 0 0 16px;
line-height: 1.5;
color: var(--el-text-color-regular);
}
.freeze-agent-options {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ─── Table toolbar间距见 App.vue 全局 .list-panel-toolbar ─── */
.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;
}
/* ─── Expansion ─── */
.expand-panel {
margin: 4px 0 8px;
padding: 12px 14px 14px;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
}
.expand-loading {
text-align: center;
padding: 16px;
color: #888;
font-size: 13px;
}
.expand-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.expand-section-title {
font-size: 13px;
font-weight: 600;
color: #888;
}
.expandable-table :deep(.row-expandable) {
cursor: pointer;
}
.compact-agent-table :deep(.el-table__header .el-table__cell) {
padding: 4px 8px;
}
.compact-agent-table :deep(.el-table__body .el-table__cell) {
padding: 6px 8px;
}
.compact-agent-table :deep(.el-table__header .cell) {
line-height: 1.25;
white-space: nowrap;
font-size: 12px;
padding: 0;
}
.compact-agent-table :deep(.el-table__body .cell) {
padding: 0;
line-height: 1.35;
font-size: 13px;
}
.compact-agent-table :deep(.el-tag) {
max-width: 100%;
}
.nested-table {
margin-bottom: 4px;
}
.inner-tabs {
margin-top: 0;
}
.inner-tabs :deep(.el-tabs__header) {
margin-bottom: 4px;
}
.inner-tabs :deep(.el-tabs__nav-wrap) {
margin-bottom: 0;
}
.inner-tabs :deep(.el-tabs__item) {
height: 34px;
line-height: 34px;
font-size: 13px;
font-weight: 500;
color: #888;
padding: 0 14px;
}
.inner-tabs :deep(.el-tabs__item.is-active) {
color: #d4fde5;
font-weight: 700;
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--gold-bright);
height: 2px;
}
.inner-tabs :deep(.el-tabs__content) {
padding: 0;
}
.inner-table {
border-radius: 6px;
}
.inner-table :deep(.el-table__cell) {
font-size: 13px;
}
.account-type-tag {
font-size: 13px;
font-weight: 600;
}
.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-actions :deep(.el-tabs__header) {
padding-right: 0;
}
.mgr-toolbar-actions {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Inherited from old pages ─── */
.settings-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
:deep(.create-account-dialog .el-dialog__body) {
max-height: none !important;
overflow: visible !important;
padding: 14px 20px 6px;
}
:deep(.create-account-dialog .el-dialog__footer) {
padding-top: 4px;
}
.compact-create-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.compact-create-form :deep(.el-form-item__label) {
font-size: 13px;
white-space: nowrap;
}
.credit-quick-row {
margin-top: 2px;
}
.credit-quick-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.create-meta-bar {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #2a2a2a;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
}
.create-meta-bar--inline {
margin-top: -4px;
}
.create-meta-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 1.4;
}
.create-meta-row + .create-meta-row {
margin-top: 6px;
}
.create-meta-label {
flex-shrink: 0;
color: #888;
}
.create-meta-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #ddd;
font-weight: 600;
}
.create-meta-row .c-green {
font-size: 14px;
}
.create-form-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 10px;
}
.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;
text-overflow: ellipsis;
}
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
.list-settings-block--danger {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed rgba(245, 108, 108, 0.35);
}
.list-settings-hint {
font-size: 12px;
color: #888;
margin: 0 0 10px;
line-height: 1.5;
}
.list-settings-unit {
margin-left: 4px;
font-size: 12px;
color: #888;
}
.reset-db-alert { margin-bottom: 10px; }
.detail-block { margin-bottom: 16px; }
.section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
}
.section-title--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.expand-panel {
background: #fbfaf7;
border-color: var(--border);
}
.expand-loading,
.expand-section-title,
.inner-tabs :deep(.el-tabs__item),
.field-hint,
.create-meta-label,
.amount-full-hint,
.text-muted,
.list-settings-hint,
.list-settings-unit,
.section-title {
color: var(--text-muted);
}
.inner-tabs :deep(.el-tabs__item.is-active) {
color: var(--text);
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--primary);
}
.create-meta-bar {
border-color: var(--border);
background: #fbfaf7;
}
.create-meta-value {
color: var(--text);
}
.c-green,
.create-meta-row .c-green {
color: var(--success-text);
}
.invite-code-cell {
color: var(--primary-link);
}
.list-settings-block--danger {
border-top-color: var(--danger-border);
}
@media (max-width: 760px) {
.list-panel-toolbar,
.list-panel-toolbar .list-chrome__grow,
.list-panel-toolbar .list-chrome__actions {
width: 100%;
}
.create-form-pair {
grid-template-columns: 1fr;
}
}
</style>