feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1644
apps/admin/src/views/AgentManager.vue
Normal file
1644
apps/admin/src/views/AgentManager.vue
Normal 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>
|
||||
Reference in New Issue
Block a user