Files
thebet365/apps/admin/src/views/agent/Players.vue
Mars c5a5e66bbd perf(admin): 实现切页即时导航与列表 loading 壳
非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
2026-06-18 16:46:57 +08:00

1695 lines
63 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">
defineOptions({ name: 'AgentPlayers' });
import { ref, computed, onMounted, onActivated, watch, reactive, h } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import { resolveFormError } from '../../i18n/form-validation';
import {
emptyAgentPlayerCreateForm,
emptyAgentPlayerEditForm,
buildAgentCreatePlayerPayload,
buildAgentUpdatePlayerPayload,
editFormFromAgentDetail,
type AgentPlayerRow,
type AgentPlayerDetail,
} from './agent-player-form';
import {
emptyAgentSubAgentCreateForm,
buildAgentSubAgentCreatePayload,
emptyAgentSubAgentEditForm,
editFormFromSubAgentDetail,
buildAgentSubAgentUpdatePayload,
subAgentAccountStatus,
type AgentSubAgentRow,
type AgentSubAgentDetail,
} from './agent-sub-agent-form';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../../utils/expandable-table';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../../components/InitialDepositRemarkField.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import InviteManageDialog from '../../components/InviteManageDialog.vue';
import AdminTableWrap from '../../components/AdminTableWrap.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 {
depositAmountCap,
parsePlayerAvailable,
type WalletTransferContext as WalletTransferContextData,
} from '../../utils/wallet-transfer-context';
import {
snapshotFromAgentRow,
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
import { formatRatePercent } from '../../utils/rate-percent';
type ScopedPlayerRow = AgentPlayerRow & {
parentAgentId?: string | null;
parentAgentUsername?: string;
cashbackRate?: string;
inChain?: boolean;
isDirect?: boolean;
};
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
const inviteDialogOpen = ref(false);
const profile = ref<{
level?: number;
maxAgentLevel?: number;
creditLimit?: string;
usedCredit?: string;
availableCredit?: string;
cashbackRate?: string;
canManageSubAgents?: boolean;
}>({});
const canManageSubAgents = computed(
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
);
const myAgentId = computed(() => String(auth.user.value?.id ?? ''));
const myAgentLevel = computed(() => profile.value.level ?? auth.user.value?.agentLevel ?? 1);
const childAgentLevel = computed(() => myAgentLevel.value + 1);
const hierarchySettings = computed(() => ({ maxAgentLevel: profile.value.maxAgentLevel ?? 0 }));
function lvlLabel(level: number) {
return formatAgentLevelNumeral(level, localeTag.value);
}
function agentTierName(level: number) {
return t('agent.level_name', { level: lvlLabel(level) });
}
function agentLevelTabName(level: number) {
return `agentLevel-${level}`;
}
const agentLevelCounts = ref<Record<number, number>>({});
function agentLevelTabLabel(level: number) {
const count = agentLevelCounts.value[level] ?? 0;
return `${agentTierName(level)} (${count})`;
}
const visibleSubAgentTabLevels = computed(() => {
if (!canManageSubAgents.value) return [] as number[];
const counts = agentLevelCounts.value;
const max = hierarchySettings.value.maxAgentLevel;
const start = childAgentLevel.value;
const levels = new Set<number>([start]);
if (max === 0) {
for (const [lvl, cnt] of Object.entries(counts)) {
const n = Number(lvl);
if (n > myAgentLevel.value && cnt > 0) levels.add(n);
}
} else {
for (let n = start + 1; n <= max; n++) {
if (n > myAgentLevel.value && (counts[n] ?? 0) > 0) levels.add(n);
}
}
return [...levels].sort((a, b) => a - b);
});
const scopedAgentOptions = computed(() =>
agentOptions.value.filter(
(a) => a.id === myAgentId.value || a.level > myAgentLevel.value,
),
);
const createSubBtnLabel = computed(() => {
if (childAgentLevel.value === 2) return t('agent.create_sub_btn');
return t('agent.create_level_agent_btn', { level: lvlLabel(childAgentLevel.value) });
});
const createSubDialogTitle = computed(() => {
if (childAgentLevel.value === 2) return t('agent_portal.create_sub_agent_dialog');
return t('agent.dialog.create_level_agent', { level: lvlLabel(childAgentLevel.value) });
});
const expandReadonlyHint = computed(() =>
t('agent_portal.sub_agent_downline_readonly_level', { level: lvlLabel(childAgentLevel.value) }),
);
/* ─── View tab: players | agentLevel-N ─── */
const activeViewTab = ref('players');
/* ─── All players (subtree) ─── */
const allPlayers = ref<ScopedPlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(20);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
const loadingPlayers = ref(false);
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Sub-agent lists by level ─── */
type SubAgentLevelState = {
agents: AgentSubAgentRow[];
total: number;
page: number;
pageSize: number;
keyword: string;
filterStatus: string;
};
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
agents: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
filterStatus: '',
};
}
return subAgentLevelState[level];
}
/* ─── Agent row expansion (direct players) ─── */
const MAX_EXPANDED_AGENT_ROWS = 2;
const expandedSet = ref(new Set<string>());
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
const agentPlayersMeta = ref<
Record<string, { total: number; page: number; pageSize: number }>
>({});
const expandLoading = ref<Record<string, boolean>>({});
const EXPAND_PLAYER_PAGE_SIZE = 20;
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref(emptyAgentPlayerCreateForm());
const editVisible = ref(false);
const editLoading = ref(false);
const editForm = ref(emptyAgentPlayerEditForm());
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<ScopedPlayerRow | null>(null);
const transferAmount = ref(100);
const transferContext = ref<WalletTransferContextData | null>(null);
const transferContextLoading = ref(false);
const createSubVisible = ref(false);
const createSubLoading = ref(false);
const createSubForm = ref(emptyAgentSubAgentCreateForm());
const editSubVisible = ref(false);
const editSubLoading = ref(false);
const editSubForm = ref(emptyAgentSubAgentEditForm());
const creditVisible = ref(false);
const creditLoading = ref(false);
const creditTarget = ref<AgentSubAgentRow | null>(null);
const creditAmount = ref(10000);
const creditRemark = ref('');
const creditContext = ref<AgentCreditAdjustContext | null>(null);
/* ─── Computed ─── */
const availableCreditNum = computed(() => {
const n = Number(profile.value.availableCredit ?? 0);
return Number.isFinite(n) ? Math.max(0, n) : 0;
});
const initialDepositRange = computed(() => ({ min: 0, max: availableCreditNum.value }));
const playerTabLabel = computed(() => `${t('user.type.player')} (${playerTotal.value})`);
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 isDirectChildAgent(row: AgentSubAgentRow) {
return row.parentAgentId === myAgentId.value;
}
function canViewPlayer(row: ScopedPlayerRow) {
return row.inChain !== false;
}
function canOperatePlayer(row: ScopedPlayerRow) {
return row.isDirect === true && canViewPlayer(row);
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
}
const transferAmountRange = computed(() => {
if (transferType.value === 'withdraw') {
const cap = parsePlayerAvailable(transferContext.value);
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
}
const cap = depositAmountCap(transferContext.value);
if (cap === undefined) return { min: 0.01, max: undefined as number | undefined };
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
});
const transferAmountDisabled = computed(() => {
const { max } = transferAmountRange.value;
return max === 0;
});
/** 勿用 el-input-number :max否则会静默截断到上限 */
const transferAmountExceedsCap = computed(() => {
const max = transferAmountRange.value.max;
if (max === undefined) return false;
return transferAmount.value > max;
});
const transferAmountCapError = computed(() => {
if (!transferAmountExceedsCap.value) return '';
return transferType.value === 'deposit'
? t('err.insufficient_credit')
: t('transfer.context.withdraw_exceed');
});
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
await loadAgentOptions();
const tab = activeViewTab.value;
if (tab === 'players') {
await loadAllPlayers();
} else if (canManageSubAgents.value) {
await reloadSubAgentTabs();
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
}
});
onActivated(() => {
const tab = activeViewTab.value;
if (tab === 'players' && allPlayers.value.length > 0) void loadAllPlayers();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
if (!subAgentLevelState[Number(m[1])]?.agents.length) {
if (canManageSubAgents.value && !Object.keys(subAgentLevelState).length) {
void reloadSubAgentTabs().then(() => loadSubAgentsAtLevel(Number(m[1])));
} else {
loadSubAgentsAtLevel(Number(m[1]));
}
}
return;
}
if (tab === 'players' && !allPlayers.value.length) void loadAllPlayers();
});
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);
}
});
async function loadProfile() {
try {
const { data } = await api.get('/agent/profile');
profile.value = data.data;
} catch { /* empty */ }
}
async function loadAgentOptions() {
try {
const { data } = await api.get('/agent/agents/options');
agentOptions.value = data.data ?? [];
} catch {
agentOptions.value = [];
}
}
async function loadAllPlayers() {
loadingPlayers.value = true;
try {
const { data } = await api.get('/agent/players/scoped', {
params: {
page: playerPage.value,
pageSize: playerPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
status: playerFilterStatus.value || undefined,
parentAgentId: playerFilterAgent.value || undefined,
},
});
allPlayers.value = (data.data.items ?? []) as ScopedPlayerRow[];
playerTotal.value = data.data.total ?? 0;
} catch {
allPlayers.value = [];
playerTotal.value = 0;
} finally {
loadingPlayers.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();
}
async function loadAgentLevelCounts() {
try {
const { data } = await api.get('/agent/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;
} catch {
agentLevelCounts.value = {};
}
}
async function loadSubAgentsAtLevel(level: number) {
const st = ensureSubAgentState(level);
try {
const { data } = await api.get('/agent/agents/by-level', {
params: {
level,
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
},
});
st.agents = (data.data.items ?? []) as AgentSubAgentRow[];
st.total = data.data.total ?? 0;
} catch {
st.agents = [];
st.total = 0;
}
}
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);
}
async function reloadSubAgentTabs() {
await loadAgentLevelCounts();
for (const lvl of visibleSubAgentTabLevels.value) {
await loadSubAgentsAtLevel(lvl);
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
function openPlayerWalletLedger(
playerId: string,
playerUsername?: string | null,
row?: ScopedPlayerRow,
) {
if (row && !canViewPlayer(row)) return;
walletLedgerPlayerId.value = playerId;
walletLedgerPlayerUsername.value = playerUsername ?? null;
walletLedgerVisible.value = true;
}
/* ─── Agent row expansion ─── */
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
const ids = expandedRows.map((r) => r.userId).slice(0, MAX_EXPANDED_AGENT_ROWS);
expandedSet.value = new Set(ids);
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
const userId = row.userId;
const next = new Set(expandedSet.value);
if (next.has(userId)) {
next.delete(userId);
} else {
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
const [first] = next;
if (first) next.delete(first);
}
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentUserId: string, page = 1) {
expandLoading.value[agentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${agentUserId}/players`, {
params: { page, pageSize: EXPAND_PLAYER_PAGE_SIZE },
});
const payload = data.data as {
items: ScopedPlayerRow[];
total: number;
page: number;
pageSize: number;
};
agentPlayersMap.value[agentUserId] = payload.items ?? [];
agentPlayersMeta.value[agentUserId] = {
total: payload.total ?? 0,
page: payload.page ?? page,
pageSize: payload.pageSize ?? EXPAND_PLAYER_PAGE_SIZE,
};
} catch {
agentPlayersMap.value[agentUserId] = [];
agentPlayersMeta.value[agentUserId] = {
total: 0,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
};
} finally {
expandLoading.value[agentUserId] = false;
}
}
function getPlayers(agentUserId: string) {
return agentPlayersMap.value[agentUserId] ?? [];
}
function getPlayersMeta(agentUserId: string) {
return (
agentPlayersMeta.value[agentUserId] ?? {
total: getPlayers(agentUserId).length,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
}
);
}
function onExpandPlayersPageChange(agentUserId: string, page: number) {
void loadExpansionData(agentUserId, page);
}
function refreshExpandedAgentPlayers() {
for (const uid of expandedSet.value) {
void loadExpansionData(uid);
}
}
/* ─── Player CRUD ─── */
function openCreate() {
createForm.value = emptyAgentPlayerCreateForm();
createVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildAgentCreatePlayerPayload>;
try {
payload = buildAgentCreatePlayerPayload(createForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
if (payload.initialDeposit != null && payload.initialDeposit > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
createLoading.value = true;
const password = createForm.value.password;
try {
await api.post('/agent/players', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
loadAllPlayers();
} 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;
}
}
async function openEdit(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
const { data } = await api.get(`/agent/players/${row.id}`);
editForm.value = editFormFromAgentDetail(data.data as AgentPlayerDetail);
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEdit() {
let payload: ReturnType<typeof buildAgentUpdatePlayerPayload>;
try {
payload = buildAgentUpdatePlayerPayload(editForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editLoading.value = true;
const newPwd = editForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/players/${editForm.value.id}`, payload);
const updated = data.data as AgentPlayerDetail;
if (newPwd) {
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
editForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
loadAllPlayers();
return;
}
ElMessage.success(t('msg.saved'));
editVisible.value = false;
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editLoading.value = false;
}
}
/* ─── Freeze ─── */
async function toggleFreeze(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
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(`/agent/players/${row.id}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Delete ─── */
async function deletePlayerRow(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
await ElMessageBox.confirm(
t('msg.delete_player_body', { name: row.username }),
t('msg.delete_player_title'),
{
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
},
);
} catch { return; }
const input = ref('');
try {
await ElMessageBox({
title: t('msg.delete_player_confirm_title'),
message: () =>
h('div', {}, [
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
t('msg.delete_player_confirm_hint', { name: row.username })),
h('input', {
value: input.value,
placeholder: row.username,
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
beforeClose: (action: string, _instance: unknown, done: () => void) => {
if (action === 'confirm') {
if (input.value.trim() !== row.username) {
ElMessage.warning(t('msg.delete_player_mismatch'));
return;
}
}
done();
},
});
} catch { return; }
try {
await api.delete(`/agent/players/${row.id}`);
ElMessage.success(t('msg.delete_player_done'));
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
}
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
transferType.value = type;
transferTarget.value = row;
transferContext.value = null;
transferVisible.value = true;
transferContextLoading.value = true;
try {
const { data } = await api.get(`/agent/players/${row.id}/transfer-context`);
transferContext.value = data.data as WalletTransferContextData;
if (type === 'deposit') {
const cap = depositAmountCap(transferContext.value);
transferAmount.value =
cap !== undefined && cap > 0 ? Math.min(100, cap) : cap === undefined ? 100 : 0;
} else {
const cap = parsePlayerAvailable(transferContext.value);
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
transferVisible.value = false;
} finally {
transferContextLoading.value = false;
}
}
async function submitTransfer() {
if (!transferTarget.value) return;
if (transferAmount.value <= 0) { ElMessage.warning(t('msg.amount_gt_zero')); return; }
const max = transferAmountRange.value.max;
if (max !== undefined && transferAmount.value > max) {
ElMessage.warning(
transferType.value === 'deposit' ? t('err.insufficient_credit') : t('transfer.context.withdraw_exceed'),
);
return;
}
const playerId = transferTarget.value.id;
const amount = transferAmount.value;
transferLoading.value = true;
try {
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${playerId}-${Date.now()}`;
if (transferType.value === 'deposit') {
await api.post(`/agent/players/${playerId}/deposit`, { amount, requestId });
ElMessage.success(t('msg.topup_ok'));
} else {
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
loadAllPlayers();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
} finally {
transferLoading.value = false;
}
}
function transferTitle() {
const name = transferTarget.value?.username ?? '';
return transferType.value === 'deposit'
? t('agent_portal.transfer_title_deposit', { name })
: t('agent_portal.transfer_title_withdraw', { name });
}
/* ─── Create sub-agent ─── */
function openCreateSub() {
createSubForm.value = emptyAgentSubAgentCreateForm();
createSubVisible.value = true;
}
async function submitCreateSub() {
let payload: ReturnType<typeof buildAgentSubAgentCreatePayload>;
try {
payload = buildAgentSubAgentCreatePayload(createSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createSubLoading.value = true;
try {
await api.post('/agent/agents', payload);
ElMessage.success(t('msg.agent_created'));
createSubVisible.value = false;
await reloadSubAgentTabs();
loadProfile();
activeViewTab.value = agentLevelTabName(childAgentLevel.value);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createSubLoading.value = false;
}
}
async function openEditSub(row: AgentSubAgentRow) {
try {
const { data } = await api.get(`/agent/agents/${row.userId}`);
editSubForm.value = editFormFromSubAgentDetail(data.data as AgentSubAgentDetail);
editSubVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEditSub() {
let payload: ReturnType<typeof buildAgentSubAgentUpdatePayload>;
try {
payload = buildAgentSubAgentUpdatePayload(editSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editSubLoading.value = true;
const newPwd = editSubForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/agents/${editSubForm.value.userId}`, payload);
const updated = data.data as AgentSubAgentDetail;
if (newPwd) {
editSubForm.value.managedPassword = updated.managedPassword ?? newPwd;
editSubForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editSubForm.value.managedPassword }));
await reloadSubAgentTabs();
return;
}
ElMessage.success(t('msg.saved'));
editSubVisible.value = false;
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editSubLoading.value = false;
}
}
function openCreditSub(row: AgentSubAgentRow) {
creditTarget.value = row;
creditAmount.value = 10000;
creditRemark.value = '';
creditContext.value = {
target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0),
}),
};
creditVisible.value = true;
}
async function submitCreditSub() {
if (!creditTarget.value) return;
if (creditAmount.value === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
if (creditAmount.value > 0 && creditAmount.value > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/agent/agents/${creditTarget.value.userId}/credit`, {
amount: creditAmount.value,
requestId: `agent-credit-${creditTarget.value.userId}-${Date.now()}`,
remark: creditRemark.value.trim() || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
await reloadSubAgentTabs();
loadProfile();
} 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;
}
}
async function toggleFreezeSub(row: AgentSubAgentRow) {
const accountStatus = subAgentAccountStatus(row);
const freeze = accountStatus === '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(`/agent/agents/${row.userId}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Helpers ─── */
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentSubAgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
function formatTime(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `user.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
</script>
<template>
<div class="admin-list-page agent-portal-mgr">
<!-- Credit strip -->
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent_portal.my_cashback_rate') }}</span>
<span class="credit-value c-green">{{ formatRatePercent(profile.cashbackRate) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.used_credit') }}</span>
<span class="credit-value">{{ formatAmount(profile.usedCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.credit_limit') }}</span>
<span class="credit-value">{{ formatAmount(profile.creditLimit) }}</span>
</div>
</div>
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="portal-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<!-- Top-level tabs -->
<el-tabs v-model="activeViewTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 玩家 -->
<el-tab-pane :label="playerTabLabel" name="players">
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="playerKeyword"
:placeholder="t('agent_portal.search_player_ph')"
clearable
style="width: 180px"
@keyup.enter="searchPlayers"
/>
</el-form-item>
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="playerFilterAgent"
:placeholder="t('user.filter.agent_ph')"
clearable
style="width: 200px"
>
<el-option
v-for="a in scopedAgentOptions"
: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" size="small" @click="searchPlayers">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreate">
+ {{ t('agent_portal.create_player_btn') }}
</el-button>
</div>
<AdminTableWrap>
<el-table v-loading="loadingPlayers" :data="allPlayers" stripe size="small" class="inner-table">
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.agent')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.parentAgentUsername ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" 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('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }">
<template v-if="canViewPlayer(row) && row.wallet?.availableBalance != null">
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
</el-tooltip>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-if="canOperatePlayer(row)"
:row="row"
:show-detail="false"
@ledger="openPlayerWalletLedger(row.id, row.username, row)"
@edit="openEdit(row)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
@delete="deletePlayerRow(row)"
/>
<el-button
v-else-if="canViewPlayer(row)"
link
type="primary"
size="small"
@click="openPlayerWalletLedger(row.id, row.username, row)"
>
{{ t('user.action.ledger_short') }}
</el-button>
<span v-else></span>
</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
small
@current-change="onPlayerPageChange"
@size-change="onPlayerSizeChange"
/>
</div>
</el-tab-pane>
<!-- Tab: 各级代理 -->
<el-tab-pane
v-for="agentLevel in visibleSubAgentTabLevels"
:key="agentLevel"
:label="agentLevelTabLabel(agentLevel)"
:name="agentLevelTabName(agentLevel)"
>
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="ensureSubAgentState(agentLevel).keyword"
:placeholder="t('agent_portal.search_sub_agent_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" size="small" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button
v-if="agentLevel === childAgentLevel"
type="primary"
size="small"
@click="openCreateSub"
>
{{ createSubBtnLabel }}
</el-button>
</div>
<AdminTableWrap>
<el-table
:data="ensureSubAgentState(agentLevel).agents"
stripe
size="small"
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="onExpandChange"
@row-click="onSubAgentRowClick"
>
<template #empty>
<div class="empty-hint">
{{ agentLevel === childAgentLevel
? (childAgentLevel === 2 ? t('agent_portal.no_sub_agents') : t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }))
: t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }) }}
</div>
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') }}
</div>
<div v-else class="expand-panel-body">
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
<div class="expand-section-title">
{{ directPlayersTabLabel(row.username, getPlayersMeta(row.userId).total) }}
</div>
<el-table
:data="getPlayers(row.userId)"
stripe
size="small"
class="inner-table nested-table"
>
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row: player }">
{{ formatRatePercent(player.cashbackRate) }}
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<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 }">
<template v-if="canViewPlayer(player) && player.wallet?.availableBalance != null">
<span>{{ formatAmount(player.wallet.availableBalance) }}</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row: player }">{{ formatTime(player.createdAt) }}</template>
</el-table-column>
</el-table>
<div
v-if="getPlayersMeta(row.userId).total > getPlayersMeta(row.userId).pageSize"
class="expand-pager"
>
<el-pagination
small
layout="total, prev, pager, next"
:total="getPlayersMeta(row.userId).total"
:current-page="getPlayersMeta(row.userId).page"
:page-size="getPlayersMeta(row.userId).pageSize"
@current-change="(p: number) => onExpandPlayersPageChange(row.userId, p)"
/>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column v-if="agentLevel > childAgentLevel" :label="t('agent.col.parent_chain')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.parentChainLabel ?? row.parentUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<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="150" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span>{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<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 prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="240" align="center">
<template #default="{ row }">
<AdminAgentRowActions
v-if="isDirectChildAgent(row)"
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:show-detail="false"
@edit="openEditSub(row)"
@credit="openCreditSub(row)"
@freeze="toggleFreezeSub(row)"
/>
<span v-else class="readonly-actions-hint">—</span>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="ensureSubAgentState(agentLevel).page"
v-model:page-size="ensureSubAgentState(agentLevel).pageSize"
:total="ensureSubAgentState(agentLevel).total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- ═══════════ DIALOGS ═══════════ -->
<!-- ── Create Player ── -->
<el-dialog v-model="createVisible" :title="t('agent_portal.create_player_dialog')" width="520px" destroy-on-close>
<el-alert
type="info" :closable="false" show-icon class="create-alert"
:title="t('agent_portal.credit_available_hint', { amount: formatAmount(profile.availableCredit) })"
/>
<el-form label-width="100px" class="create-form">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</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.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>
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="initialDepositRange.min" :max="initialDepositRange.max" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.initial_deposit_hint') }}</div>
</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="agent"
/>
</el-form-item>
</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="editVisible" :title="t('agent_portal.edit_player_dialog')" 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 {{ editForm.id }}</span>
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.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="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</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="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty">—</span>
</div>
<p v-if="!editForm.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="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</div>
<div 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="editForm.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="editForm.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(editForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- ── 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>
<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>
<!-- ── Create Sub-Agent ── -->
<el-dialog v-model="createSubVisible" :title="createSubDialogTitle" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createSubForm.username" :placeholder="t('agent_portal.agent_username_ph')" />
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createSubForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createSubForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number v-model="createSubForm.creditLimit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createSubForm.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="createSubForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createSubLoading" @click="submitCreateSub">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- ── Edit Sub-Agent ── -->
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close class="agent-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editSubForm.userId }}</span>
<el-tag :type="statusTagType(editSubForm.status)" size="small">{{ statusLabel(editSubForm.status) }}</el-tag>
<el-tag size="small" type="info">L{{ editSubForm.level }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editSubForm.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="editSubForm.managedPassword" class="password-plain">{{ editSubForm.managedPassword }}</span>
<span v-else class="password-empty">—</span>
</el-form-item>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editSubForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editSubForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editSubForm.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(editSubForm.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editSubForm.availableCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editSubForm.directPlayerCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
{{ editSubForm.lastLoginAt ? formatTime(editSubForm.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editSubLoading" @click="submitEditSub">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- ── Adjust Sub-Agent Credit ── -->
<el-dialog
v-model="creditVisible"
:title="t('agent_portal.adjust_credit_dialog', { name: creditTarget?.username ?? '' })"
width="520px"
destroy-on-close
>
<AgentCreditContext
:context="creditContext"
:adjust-amount="creditAmount"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ creditTarget?.userId }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number v-model="creditAmount" :step="1000" :precision="2" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.credit_adjust_hint') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditRemark" :placeholder="t('common.optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<PlayerWalletLedgerDialog
v-model="walletLedgerVisible"
:player-id="walletLedgerPlayerId"
:player-username="walletLedgerPlayerUsername"
/>
</div>
</template>
<style scoped>
/* ─── Credit strip ─── */
.credit-strip {
display: flex; align-items: center; gap: 20px;
padding: 14px 18px; margin-bottom: 16px;
border-radius: 12px; border: 1px solid #1e1e1e;
background: linear-gradient(135deg, rgba(30,30,30,.6), rgba(20,20,20,.4));
}
.credit-item { display: flex; flex-direction: column; gap: 4px; }
.credit-label { font-size: 12px; color: #666; }
.credit-value { font-size: 18px; font-weight: 700; color: #e0e0e0; font-variant-numeric: tabular-nums; }
.credit-value.c-green { color: var(--gold-text); }
.credit-divider { width: 1px; height: 32px; background: #2a2a2a; }
/* ─── Tabs ─── */
.portal-tabs-shell {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.portal-top-tabs { margin-bottom: 0; }
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
margin-bottom: 0;
padding-right: 108px;
}
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 96px;
height: 38px;
padding: 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(201, 162, 39, 0.22);
}
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--gold-text);
}
/* ─── Inner content ─── */
.inner-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
.inner-table { border-radius: 6px; }
.expandable-table :deep(.row-expandable) { cursor: pointer; }
.agent-portal-mgr :deep(.el-table) {
min-width: 720px;
}
.inner-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
@media (max-width: 900px) {
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Expansion ─── */
.expand-panel { padding: 4px 16px 8px; }
.expand-loading { text-align: center; padding: 16px; color: #888; font-size: 13px; }
.expand-section-title { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 6px; }
.expand-section-title--spaced { margin-top: 14px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
.expand-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* ─── Shared ─── */
.field-hint { margin-top: 6px; font-size: 12px; color: #666; line-height: 1.4; }
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
.create-alert { margin-bottom: 16px; }
.create-form { margin-top: 4px; }
.credit-strip {
border-color: var(--border);
background: #ffffff;
box-shadow: var(--shadow);
}
.credit-label,
.expand-loading,
.expand-section-title,
.expand-readonly-hint,
.field-hint,
.empty-hint {
color: var(--text-muted);
}
.credit-value {
color: var(--text);
}
.credit-value.c-green,
.invite-code-cell {
color: var(--success-text);
}
.credit-divider {
background: var(--border);
}
.invite-prominent-btn {
box-shadow: none;
}
.expand-panel {
margin-top: 6px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
@media (max-width: 760px) {
.credit-strip {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.credit-divider {
width: 100%;
height: 1px;
}
.inner-toolbar,
.expand-pager {
justify-content: flex-start;
}
}
</style>