feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情

新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 15:34:12 +08:00
parent b2216abd0c
commit 414998ce36
54 changed files with 6641 additions and 481 deletions

View File

@@ -0,0 +1,1644 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { clearStaffSession } from '../stores/auth';
const { t, localeTag } = useAdminLocale();
const router = useRouter();
import {
emptyPlayerCreateForm,
emptyPlayerEditForm,
editFormFromDetail,
buildCreatePlayerPayload,
type PlayerRow,
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
} from './user-form.ts';
import {
emptyAgentEditForm,
editFormFromAgentDetail,
type AgentRow,
type AgentDetail,
type AgentEditForm,
} from './agent-form.ts';
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
import {
formatAmount,
formatAmountFull,
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../utils/expandable-table';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
import {
fetchAdminAgentCreditContext,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
/* ─── Main agent list ─── */
const agents = ref<AgentRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref('');
const filterStatus = ref('');
/* ─── Expansion state ─── */
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const agentSubAgentsMap = ref<Record<string, AgentRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const innerTabMap = ref<Record<string, string>>({});
const agentTableRef = ref();
/* Sub-agent nested expansion (players under tier-2 agents) */
const subAgentExpandedKeys = ref<string[]>([]);
const subAgentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
/* ─── 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(''); // set when creating from expansion
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);
/* ─── Global settings ─── */
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const agentSuspendSettings = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
const agentSuspendSaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
const settingsCollapseOpen = ref<string[]>([]);
/* ─── Init ─── */
onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadAgentSuspendSettings();
loadResetDatabaseStatus();
load();
});
/* ─── Load main agent list ─── */
async function load() {
const { data } = await api.get('/admin/agents', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
},
});
agents.value = data.data.items as AgentRow[];
total.value = data.data.total;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
/* ─── Expansion ─── */
function getInnerTab(agentId: string) {
return innerTabMap.value[agentId] || 'players';
}
function setInnerTab(agentId: string, tab: string) {
innerTabMap.value[agentId] = tab;
}
async function onExpandChange(row: AgentRow, expandedRows: AgentRow[]) {
// Track expanded rows
expandedSet.value = new Set(expandedRows.map(r => r.userId));
// Load data if newly expanded
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
agentTableRef.value?.toggleRowExpansion(row);
}
async function loadExpansionData(agentId: string) {
expandLoading.value[agentId] = true;
try {
const [playersRes, subAgentsRes] = await Promise.all([
api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }),
api.get('/admin/agents', { params: { parentAgentId: agentId, pageSize: 100 } }),
]);
agentPlayersMap.value[agentId] = playersRes.data.data.items;
agentSubAgentsMap.value[agentId] = subAgentsRes.data.data.items;
} catch {
agentPlayersMap.value[agentId] = [];
agentSubAgentsMap.value[agentId] = [];
} finally {
expandLoading.value[agentId] = false;
}
}
function getPlayers(agentId: string) {
return agentPlayersMap.value[agentId] || [];
}
function getSubAgents(agentId: string) {
return agentSubAgentsMap.value[agentId] || [];
}
async function loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
try {
const { data } = await api.get('/admin/users', {
params: { parentId: subAgentUserId, pageSize: 100 },
});
subAgentPlayersMap.value[subAgentUserId] = data.data.items as PlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
}
}
function getSubAgentPlayers(subAgentUserId: string) {
return subAgentPlayersMap.value[subAgentUserId] || [];
}
function onSubAgentExpand(_parentAgentId: string, row: AgentRow, expandedRows: AgentRow[]) {
subAgentExpandedKeys.value = expandedRows.map((r) => r.userId);
if (!subAgentPlayersMap.value[row.userId]) {
loadSubAgentPlayers(row.userId);
}
}
function onSubAgentRowClick(_parentAgentId: string, row: AgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
const id = row.userId;
if (subAgentExpandedKeys.value.includes(id)) {
subAgentExpandedKeys.value = subAgentExpandedKeys.value.filter((k) => k !== id);
} else {
subAgentExpandedKeys.value = [...subAgentExpandedKeys.value, id];
if (!subAgentPlayersMap.value[id]) {
loadSubAgentPlayers(id);
}
}
}
function refreshExpandedSubAgentPlayers() {
for (const subId of subAgentExpandedKeys.value) {
loadSubAgentPlayers(subId);
}
}
/* ─── Global settings ─── */
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
async function loadBettingLimits() {
try {
const { data } = await api.get('/admin/settings/betting-limits');
bettingLimits.value = data.data;
} catch {
/* defaults */
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadBettingLimits();
} finally {
limitsSaving.value = false;
}
}
async function loadPlayerSettings() {
try {
const { data } = await api.get('/admin/users/settings/account');
playerSettings.value = data.data;
} catch {
/* defaults */
}
}
async function savePlayerSettings() {
settingsSaving.value = true;
try {
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
playerSettings.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadPlayerSettings();
} finally {
settingsSaving.value = false;
}
}
async function loadAgentSuspendSettings() {
try {
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendSettings.value = data.data;
} catch {
/* defaults */
}
}
async function saveAgentSuspendSettings() {
agentSuspendSaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
agentSuspendSettings.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadAgentSuspendSettings();
} finally {
agentSuspendSaving.value = false;
}
}
/* ─── Create (unified) ─── */
function openCreateGlobal() {
createForm.value = emptyPlayerCreateForm();
createParentAgentId.value = '';
createAccountMode.value = 0;
createVisible.value = true;
}
function openCreatePlayer(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createForm.value.parentId = parentAgentUserId;
createParentAgentId.value = parentAgentUserId;
createAccountMode.value = 0;
createVisible.value = true;
}
function openCreateSubAgent(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = parentAgentUserId;
createAccountMode.value = 2;
createVisible.value = true;
}
function onAccountModeChange(mode: number) {
createAccountMode.value = mode;
if (mode === 0) {
// Player
createForm.value.asTier1Agent = false;
if (createParentAgentId.value) {
createForm.value.parentId = createParentAgentId.value;
}
} else if (mode === 1) {
// Tier-1 agent
createForm.value.asTier1Agent = true;
createForm.value.parentId = '';
} else if (mode === 2) {
// Sub-agent
createForm.value.asTier1Agent = false;
createForm.value.parentId = '';
}
}
async function submitCreate() {
const isSubAgent = createAccountMode.value === 2;
const isAgent = createForm.value.asTier1Agent || isSubAgent;
let payload: Record<string, unknown>;
try {
if (isSubAgent) {
// Sub-agent creation
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'));
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: 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;
load();
refreshExpandedParents();
const parentId = createParentAgentId.value || createForm.value.parentId;
if (parentId) {
await loadSubAgentPlayers(parentId);
}
} 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;
}
editPlayerLoading.value = true;
try {
const newPwd = editPlayerForm.value.newPassword.trim();
const { data } = await api.put(`/admin/users/${editingId.value}`, {
username: editPlayerForm.value.username.trim(),
parentId: editPlayerForm.value.parentId || '',
phone: editPlayerForm.value.phone.trim() || undefined,
email: editPlayerForm.value.email.trim() || undefined,
password: newPwd || undefined,
});
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: 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) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? 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;
}
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) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? 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 }));
}
}
/* ─── Freeze / Unfreeze Agent ─── */
async function toggleFreezeAgent(row: AgentRow) {
const accountStatus = subAgentAccountStatus(row);
const freeze = accountStatus === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
if (!freeze) {
// Unfreeze: simple confirm
try {
await ElMessageBox.confirm(
t('agent.freeze.confirm_unfreeze_body', { name: row.username }),
t('agent.freeze.confirm_freeze_title'),
{ type: 'info', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch {
return;
}
try {
await api.put(`/admin/agents/${row.userId}`, { status: 'ACTIVE' });
ElMessage.success(t('agent.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 }));
}
return;
}
// Freeze: offer cascade option via a custom dialog
let freezeDirectPlayers = false;
try {
await ElMessageBox.confirm(
t('agent.freeze.confirm_freeze_body', { name: row.username }),
t('agent.freeze.confirm_freeze_title'),
{
type: 'warning',
confirmButtonText: action,
cancelButtonText: t('common.cancel'),
distinguishCancelAndClose: true,
},
);
} catch {
return;
}
// After confirming freeze, ask about cascade (only when enabled in settings)
if (row.directPlayerCount > 0 && agentSuspendSettings.value.suspendFreezeDirectPlayers) {
try {
await ElMessageBox.confirm(
t('agent.freeze.cascade_hint'),
t('agent.freeze.cascade_label'),
{
type: 'warning',
confirmButtonText: t('common.yes') || '是',
cancelButtonText: t('common.no') || '否',
distinguishCancelAndClose: true,
},
);
freezeDirectPlayers = true;
} catch {
freezeDirectPlayers = false;
}
} else if (row.directPlayerCount > 0 && !agentSuspendSettings.value.suspendFreezeDirectPlayers) {
ElMessage.info(t('agent.suspend.cascade_disabled_hint'));
}
try {
await api.put(`/admin/agents/${row.userId}`, {
status: 'SUSPENDED',
freezeDirectPlayers,
});
ElMessage.success(
freezeDirectPlayers
? t('agent.msg.cascade_freeze_done')
: t('agent.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 }));
}
}
/* ─── Helpers ─── */
function refreshExpandedParents() {
for (const agentId of expandedSet.value) {
loadExpansionData(agentId);
}
refreshExpandedSubAgentPlayers();
}
const {
transferVisible,
transferLoading,
transferType,
transferTarget,
transferAmount,
transferRemark,
transferContext,
transferContextLoading,
transferAmountRange,
transferAmountDisabled,
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;
}
</script>
<template>
<div class="admin-list-page agent-mgr-page">
<!-- Global settings collapse -->
<el-collapse v-model="settingsCollapseOpen" class="list-settings">
<el-collapse-item :title="t('user.page_settings')" name="settings">
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.suspend.freeze_direct_players')">
<el-switch
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
:loading="agentSuspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
<el-form-item :label="t('agent.suspend.block_player_login')">
<el-switch
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
:loading="agentSuspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert v-if="!resetAllowed" type="warning" :closable="false" show-icon class="reset-db-alert" :title="t('user.reset_database_disabled_prod')" />
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input v-model="resetConfirmPhrase" :placeholder="t('user.reset_database_confirm_ph')" style="width: 160px" :disabled="!resetAllowed" autocomplete="off" />
</el-form-item>
<el-form-item>
<el-button type="danger" plain :loading="resetLoading" :disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'" @click="resetDatabase">{{ t('user.reset_database_btn') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<!-- Filter bar -->
<div class="list-chrome">
<div class="list-chrome__row">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input v-model="keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="load" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="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="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreateGlobal">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
</div>
<!-- Agent table -->
<section class="list-panel">
<div class="table-wrap">
<el-table
ref="agentTableRef"
:data="agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table"
@expand-change="onExpandChange"
@row-click="onAgentRowClick"
>
<template #empty>
<AdminTableEmpty />
</template>
<!-- Built-in expand column -->
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<template v-else>
<el-tabs :model-value="getInnerTab(row.userId)" @update:model-value="setInnerTab(row.userId, $event)" class="inner-tabs">
<!-- Players tab -->
<el-tab-pane :label="`${t('nav.players')} (${getPlayers(row.userId).length})`" name="players">
<div class="inner-toolbar">
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">
+ {{ t('user.create_btn') }}
</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe size="small" class="inner-table">
<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('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="100">
<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>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Sub-agents tab -->
<el-tab-pane :label="`${t('nav.subAgents')} (${getSubAgents(row.userId).length})`" name="subAgents">
<div class="inner-toolbar">
<el-button type="primary" size="small" @click="openCreateSubAgent(row.userId)">
+ {{ t('agent.create_sub') || '创建二级代理' }}
</el-button>
</div>
<el-table
:data="getSubAgents(row.userId)"
stripe
size="small"
row-key="userId"
:expand-row-keys="subAgentExpandedKeys"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)"
@row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)"
>
<template #empty><AdminTableEmpty /></template>
<el-table-column type="expand">
<template #default="{ row: sub }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[sub.userId]" class="expand-loading">
{{ t('common.loading') }}
</div>
<template v-else>
<div class="expand-section-header">
<div class="expand-section-title">
{{ t('nav.players') }} ({{ getSubAgentPlayers(sub.userId).length }})
</div>
<el-button type="primary" size="small" @click.stop="openCreatePlayer(sub.userId)">
+ {{ t('agent_portal.create_player_btn').replace(/^\+ /, '') }}
</el-button>
</div>
<el-table :data="getSubAgentPlayers(sub.userId)" stripe size="small" class="inner-table nested-table">
<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('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">
{{ statusLabel(player.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }}</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" @click.stop>
<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>
</template>
</el-table-column>
</el-table>
</template>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: sub }">
<el-tag :type="statusTagType(subAgentAccountStatus(sub))" size="small">
{{ statusLabel(subAgentAccountStatus(sub)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
<template #default="{ row: sub }">
<el-tooltip :content="creditLineFull(sub)" placement="top">
<span class="amount-compact">{{ creditLine(sub) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="70" align="right">
<template #default="{ row: sub }">{{ sub.cashbackRate }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
<template #default="{ row: sub }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailAgent(sub.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(sub.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(sub.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button
v-if="subAgentAccountStatus(sub) === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreezeAgent(sub)"
>{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(sub)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<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')" width="60" align="center">
<template #default="{ row }">L{{ row.level }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="168" 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')" width="80" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="80" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<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="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>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</section>
<!-- ═══════════ DIALOGS ═══════════ -->
<!-- ── Create (unified) ── -->
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item>
<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>
<el-form-item :label="t('user.field.account_type')">
<el-radio-group :model-value="createAccountMode" @update:model-value="onAccountModeChange">
<el-radio :value="0">{{ t('user.type.player') }}</el-radio>
<el-radio :value="1" :disabled="!!createParentAgentId">{{ t('user.type.tier1_agent') }}</el-radio>
<el-radio :value="2" :disabled="!createParentAgentId">{{ t('user.type.sub_agent') }}</el-radio>
</el-radio-group>
<div class="field-hint">
<template v-if="createParentAgentId">
{{ t('agent.hint.creating_under_agent') }}
</template>
<template v-else>{{ t('user.hint.account_type') }}</template>
</div>
</el-form-item>
<!-- Player fields (when mode is player and no parent agent context) -->
<template v-if="createAccountMode === 0 && !createParentAgentId">
<el-form-item :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 agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
</el-select>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
</el-form-item>
</template>
<!-- Agent fields (tier1 or sub-agent) -->
<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" :step="10000" style="width: 100%" />
<div 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%" />
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
</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%" />
<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="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</template>
<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>
<!-- Player-only: initial balance -->
<template v-if="createAccountMode === 0">
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</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="t('user.dialog.edit')" width="480px" 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>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editPlayerForm.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="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty">—</span>
</el-form-item>
<p v-if="!editPlayerForm.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="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.filter.agent')">
<el-select v-model="editPlayerForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
</el-select>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :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 }) }}
</el-descriptions-item>
</el-descriptions>
</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">{{ t('user.btn.save_profile') }}</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')">
<el-input-number v-model="editAgentForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
</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')">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min"
:max="transferAmountRange.max"
: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"
@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="560px" destroy-on-close>
<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')">
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ playerDetail.parentUsername ?? t('common.platform_direct') }}</el-descriptions-item>
<el-descriptions-item :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')">
{{ 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')">
{{ 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>
</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')">{{ 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>
<div class="section-title">{{ t('agent.section.credit_log') }}</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>
</div>
</template>
<style scoped>
/* ─── Expansion ─── */
.expand-panel {
padding: 4px 16px 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;
}
.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: 32px;
line-height: 32px;
font-size: 13px;
padding: 0 14px;
}
.inner-tabs :deep(.el-tabs__content) {
padding: 0;
}
.inner-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
.inner-table {
border-radius: 6px;
}
.expandable-table :deep(.row-expandable) {
cursor: pointer;
}
.action-btns {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
}
/* ─── 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; }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
.password-mgmt-block {
margin: 4px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.password-plain {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
letter-spacing: 0.06em;
}
.password-empty { color: #666; }
.block-hint { margin: -4px 0 8px; }
.edit-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 12px;
color: #888;
}
.compact-edit-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.edit-stats { margin-top: 4px; }
.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;
}
.reset-db-alert { margin-bottom: 10px; }
.detail-block { margin-bottom: 16px; }
.section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 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>