重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
2763 lines
107 KiB
Vue
2763 lines
107 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed, watch, reactive, h } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
|
||
import api from '../api';
|
||
import { clearStaffSession } from '../stores/auth';
|
||
|
||
const { t, localeTag } = useAdminLocale();
|
||
const router = useRouter();
|
||
|
||
import {
|
||
emptyPlayerCreateForm,
|
||
emptyPlayerEditForm,
|
||
editFormFromDetail,
|
||
buildCreatePlayerPayload,
|
||
type PlayerRow,
|
||
type PlayerDetail,
|
||
type PlayerCreateForm,
|
||
type PlayerEditForm,
|
||
formatPlayerAffiliationLabel,
|
||
assertPlayerUsername,
|
||
} from './user-form';
|
||
import {
|
||
emptyAgentEditForm,
|
||
editFormFromAgentDetail,
|
||
type AgentRow,
|
||
type AgentDetail,
|
||
type AgentEditForm,
|
||
} from './agent-form';
|
||
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
||
|
||
type DisplayAgentRow = AgentRow;
|
||
import {
|
||
formatAmount,
|
||
formatAmountFull,
|
||
shouldCompactAmount as shouldCompact,
|
||
} from '../utils/format-amount';
|
||
import { formatAgentLevelNumeral } from '../utils/agent-level-label';
|
||
import {
|
||
shouldToggleExpandOnRowClick,
|
||
expandableTableRowClassName,
|
||
} from '../utils/expandable-table';
|
||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
|
||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||
import InitialDepositRemarkField from '../components/InitialDepositRemarkField.vue';
|
||
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
||
import RatePercentInput from '../components/RatePercentInput.vue';
|
||
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
|
||
import InviteCodePanel from '../components/InviteCodePanel.vue';
|
||
import InviteManageDialog from '../components/InviteManageDialog.vue';
|
||
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
||
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
|
||
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
|
||
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
||
import AdminDetailItem from '../components/AdminDetailItem.vue';
|
||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||
import {
|
||
fetchAdminAgentCreditContext,
|
||
maxCreditIncreaseAmount,
|
||
type AgentCreditAdjustContext,
|
||
} from '../utils/agent-credit-context';
|
||
|
||
const inviteDialogOpen = ref(false);
|
||
|
||
/* ─── Tier-1 agent list ─── */
|
||
const tier1Agents = ref<AgentRow[]>([]);
|
||
const tier1Total = ref(0);
|
||
const tier1Page = ref(1);
|
||
const tier1PageSize = ref(20);
|
||
const tier1Keyword = ref('');
|
||
const tier1FilterStatus = ref('');
|
||
|
||
/* ─── Sub-agent lists by level (L2, L3, …) ─── */
|
||
type SubAgentLevelState = {
|
||
agents: AgentRow[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
keyword: string;
|
||
filterStatus: string;
|
||
};
|
||
|
||
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
|
||
const agentLevelCounts = ref<Record<number, number>>({});
|
||
|
||
function ensureSubAgentState(level: number): SubAgentLevelState {
|
||
if (!subAgentLevelState[level]) {
|
||
subAgentLevelState[level] = {
|
||
agents: [],
|
||
total: 0,
|
||
page: 1,
|
||
pageSize: 20,
|
||
keyword: '',
|
||
filterStatus: '',
|
||
};
|
||
}
|
||
return subAgentLevelState[level];
|
||
}
|
||
|
||
function agentLevelTabName(level: number) {
|
||
return `agentLevel-${level}`;
|
||
}
|
||
|
||
function lvlLabel(level: number) {
|
||
return formatAgentLevelNumeral(level, localeTag.value);
|
||
}
|
||
|
||
function agentTierName(level: number) {
|
||
return t('agent.level_name', { level: lvlLabel(level) });
|
||
}
|
||
|
||
function agentLevelTabLabel(level: number) {
|
||
const count = agentLevelCounts.value[level] ?? 0;
|
||
return `${agentTierName(level)} (${count})`;
|
||
}
|
||
|
||
const visibleSubAgentTabLevels = computed(() => {
|
||
const counts = agentLevelCounts.value;
|
||
const max = hierarchySettings.value.maxAgentLevel;
|
||
const levels = new Set<number>([2]);
|
||
if (max === 0) {
|
||
for (const [lvl, cnt] of Object.entries(counts)) {
|
||
const n = Number(lvl);
|
||
if (n >= 3 && cnt > 0) levels.add(n);
|
||
}
|
||
} else {
|
||
for (let n = 3; n <= max; n++) {
|
||
if ((counts[n] ?? 0) > 0) levels.add(n);
|
||
}
|
||
}
|
||
return [...levels].sort((a, b) => a - b);
|
||
});
|
||
|
||
/* ─── View tab: players | tier1Agents | agentLevel-N ─── */
|
||
const activeViewTab = ref('players');
|
||
|
||
/* ─── All players list ─── */
|
||
const allPlayers = ref<PlayerRow[]>([]);
|
||
const playerTotal = ref(0);
|
||
const playerPage = ref(1);
|
||
const playerPageSize = ref(20);
|
||
const playerKeyword = ref('');
|
||
const playerFilterStatus = ref('');
|
||
const playerFilterAgent = ref('');
|
||
const playerLoading = ref(false);
|
||
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
|
||
|
||
/* ─── Expansion state ─── */
|
||
const expandedSet = ref(new Set<string>());
|
||
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
||
const expandLoading = ref<Record<string, boolean>>({});
|
||
|
||
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
|
||
|
||
const createToolbarChildLevel = ref<number | null>(null);
|
||
|
||
/* ─── Dialogs ─── */
|
||
const createVisible = ref(false);
|
||
const editPlayerVisible = ref(false);
|
||
const editAgentVisible = ref(false);
|
||
const detailPlayerVisible = ref(false);
|
||
const detailAgentVisible = ref(false);
|
||
const creditVisible = ref(false);
|
||
const createLoading = ref(false);
|
||
const editPlayerLoading = ref(false);
|
||
const editAgentLoading = ref(false);
|
||
const creditLoading = ref(false);
|
||
|
||
/* ─── Create form (unified) ─── */
|
||
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
|
||
const createParentAgentId = ref('');
|
||
const createParentLocked = ref(false);
|
||
const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent
|
||
|
||
/* ─── Edit forms ─── */
|
||
const editPlayerForm = ref<PlayerEditForm>(emptyPlayerEditForm());
|
||
const editAgentForm = ref<AgentEditForm>(emptyAgentEditForm());
|
||
const editingId = ref('');
|
||
|
||
/* ─── Detail ─── */
|
||
const playerDetail = ref<PlayerDetail | null>(null);
|
||
const agentDetail = ref<AgentDetail | null>(null);
|
||
|
||
/* ─── Credit ─── */
|
||
const creditForm = ref({ amount: 10000, remark: '' });
|
||
const creditContext = ref<AgentCreditAdjustContext | null>(null);
|
||
const creditContextLoading = ref(false);
|
||
|
||
/* ─── 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 hierarchySettings = ref({ maxAgentLevel: 0 });
|
||
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
|
||
const freezeAgentVisible = ref(false);
|
||
const freezeAgentLoading = ref(false);
|
||
const freezeAgentTarget = ref<AgentRow | null>(null);
|
||
const freezeAgentForm = ref({
|
||
freezeDirectPlayers: false,
|
||
blockDirectPlayerLogin: false,
|
||
unfreezeDirectPlayers: false,
|
||
});
|
||
const hierarchySaving = ref(false);
|
||
const platformDirectRate = ref(0);
|
||
const adminInviteRate = ref(0);
|
||
const platformDirectSaving = ref(false);
|
||
const resetAllowed = ref(false);
|
||
const resetLoading = ref(false);
|
||
const resetConfirmPhrase = ref('');
|
||
const settingsCollapseOpen = ref<string[]>([]);
|
||
const settingsLoaded = ref(false);
|
||
const resetDbStatusLoaded = ref(false);
|
||
const agentOptionsLoading = ref(false);
|
||
const MAX_EXPANDED_AGENT_ROWS = 2;
|
||
|
||
const createDialogTitle = computed(() => {
|
||
if (createAccountMode.value === 1) return t('agent.dialog.create');
|
||
if (createAccountMode.value === 2) {
|
||
const lvl = createTargetAgentLevel.value;
|
||
if (lvl === 2) return t('agent_portal.create_sub_agent_dialog');
|
||
if (lvl != null) return t('agent.dialog.create_level_agent', { level: lvlLabel(lvl) });
|
||
return t('agent.dialog.create_child_agent');
|
||
}
|
||
return t('user.dialog.create');
|
||
});
|
||
|
||
const createSubAgentLevelPreview = computed(() => {
|
||
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
|
||
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
|
||
return parentLevel != null ? parentLevel + 1 : null;
|
||
});
|
||
|
||
const createTargetAgentLevel = computed(() => {
|
||
if (createAccountMode.value !== 2) return null;
|
||
if (createToolbarChildLevel.value != null) return createToolbarChildLevel.value;
|
||
return createSubAgentLevelPreview.value;
|
||
});
|
||
|
||
function resolveParentAgentLevel(agentId: string): number | null {
|
||
const opt = agentOptions.value.find((a) => a.id === agentId);
|
||
if (opt) return opt.level;
|
||
const row = findAgentRowByUserId(agentId);
|
||
if (row) return row.level;
|
||
return null;
|
||
}
|
||
|
||
function parentOptionsForChildLevel(childLevel: number) {
|
||
if (childLevel < 2) return [];
|
||
const requiredParentLevel = childLevel - 1;
|
||
return parentAgentOptionsForCreate.value.filter((a) => a.level === requiredParentLevel);
|
||
}
|
||
|
||
function childAgentActionLabel(parentLevel: number) {
|
||
return t('agent.create_level_agent', { level: lvlLabel(parentLevel + 1) });
|
||
}
|
||
|
||
const createParentCreditCache = ref<Record<string, string>>({});
|
||
|
||
function findAgentRowByUserId(userId: string): AgentRow | undefined {
|
||
const tier1 = tier1Agents.value.find((a) => a.userId === userId);
|
||
if (tier1) return tier1;
|
||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||
const hit = subAgentLevelState[lvl]?.agents.find((a) => a.userId === userId);
|
||
if (hit) return hit;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
async function ensureCreateParentCredit(agentId: string) {
|
||
const hit = findAgentRowByUserId(agentId);
|
||
if (hit) {
|
||
createParentCreditCache.value[agentId] = hit.availableCredit;
|
||
return;
|
||
}
|
||
if (createParentCreditCache.value[agentId]) return;
|
||
try {
|
||
const { data } = await api.get(`/admin/agents/${agentId}`);
|
||
createParentCreditCache.value[agentId] = String(data.data.availableCredit ?? '0');
|
||
} catch {
|
||
createParentCreditCache.value[agentId] = '0';
|
||
}
|
||
}
|
||
|
||
const createParentAvailableCredit = computed(() => {
|
||
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
|
||
const id = createParentAgentId.value;
|
||
const hit = findAgentRowByUserId(id);
|
||
if (hit) return hit.availableCredit;
|
||
return createParentCreditCache.value[id] ?? null;
|
||
});
|
||
|
||
const createSubCreditMax = computed(() => {
|
||
const n = Number(createParentAvailableCredit.value ?? 0);
|
||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||
});
|
||
|
||
function computeSubAgentCreditByRatio(available: number, ratioPercent: number): number {
|
||
if (available <= 0) return 0;
|
||
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
|
||
const raw = available * pct;
|
||
const rounded = pct >= 1 ? available : Math.floor(raw / 100) * 100;
|
||
return Math.min(available, Math.max(0, rounded));
|
||
}
|
||
|
||
const creditQuickRatios = [10, 15, 20, 30] as const;
|
||
|
||
function computeDefaultSubAgentCreditLimit(available: number): number {
|
||
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
|
||
}
|
||
|
||
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
|
||
createForm.value.creditLimit = computeSubAgentCreditByRatio(createSubCreditMax.value, ratioPercent);
|
||
}
|
||
|
||
function applyCreateSubAgentDefaultCredit() {
|
||
if (createAccountMode.value !== 2) return;
|
||
createForm.value.creditLimit = computeDefaultSubAgentCreditLimit(createSubCreditMax.value);
|
||
}
|
||
|
||
watch(
|
||
() => [createVisible.value, createAccountMode.value, createParentAgentId.value] as const,
|
||
([visible, mode, parentId]) => {
|
||
if (!visible || mode !== 2 || !parentId) return;
|
||
void ensureCreateParentCredit(parentId).then(() => {
|
||
applyCreateSubAgentDefaultCredit();
|
||
});
|
||
},
|
||
);
|
||
|
||
function canAgentCreateSub(row: Pick<AgentRow, 'level'>) {
|
||
const max = hierarchySettings.value.maxAgentLevel;
|
||
if (max === 0) return true;
|
||
return row.level < max;
|
||
}
|
||
|
||
function parentChainLabel(row: Pick<AgentRow, 'parentChainLabel' | 'parentUsername'>) {
|
||
if (row.parentChainLabel) return row.parentChainLabel;
|
||
return row.parentUsername ?? '—';
|
||
}
|
||
|
||
const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1));
|
||
|
||
const parentAgentOptionsForCreate = computed(() => {
|
||
const max = hierarchySettings.value.maxAgentLevel;
|
||
return agentOptions.value.filter((a) => max === 0 || a.level < max);
|
||
});
|
||
|
||
const createParentSelectOptions = computed(() => {
|
||
const childLevel = createToolbarChildLevel.value;
|
||
if (childLevel == null || childLevel < 2) return [];
|
||
return parentOptionsForChildLevel(childLevel);
|
||
});
|
||
|
||
const createParentSelectPlaceholder = computed(() => {
|
||
const childLevel = createToolbarChildLevel.value;
|
||
if (childLevel == null || childLevel < 2) return t('user.filter.agent_ph');
|
||
return t('agent.hint.select_parent_for_level', { level: lvlLabel(childLevel - 1) });
|
||
});
|
||
|
||
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
|
||
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
|
||
return `L${a.level} ${chain} (#${a.id})`;
|
||
}
|
||
|
||
function resolveCreateParentLabel(agentId: string) {
|
||
const opt = agentOptions.value.find((a) => a.id === agentId);
|
||
if (opt) return agentOptionLabel(opt);
|
||
const tier1 = tier1Agents.value.find((a) => a.userId === agentId);
|
||
if (tier1) return tier1.username;
|
||
const row = findAgentRowByUserId(agentId);
|
||
if (row) {
|
||
return row.parentUsername ? `${row.parentUsername} / ${row.username}` : row.username;
|
||
}
|
||
return agentId;
|
||
}
|
||
|
||
/* ─── Init ─── */
|
||
onMounted(() => {
|
||
void loadUsersPageInit();
|
||
loadAllPlayers();
|
||
loadTier1Agents();
|
||
});
|
||
|
||
async function loadUsersPageInit() {
|
||
try {
|
||
const { data } = await api.get('/admin/users/page-init');
|
||
const payload = data.data as {
|
||
playerSettings?: typeof playerSettings.value;
|
||
bettingLimits?: typeof bettingLimits.value;
|
||
hierarchySettings?: { maxAgentLevel: number };
|
||
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
|
||
agentLevelCounts?: Record<number, number>;
|
||
};
|
||
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
|
||
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
|
||
if (payload.hierarchySettings) {
|
||
hierarchySettings.value = {
|
||
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
|
||
};
|
||
}
|
||
if (payload.platformDirect) {
|
||
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
|
||
adminInviteRate.value = decimalRateToPercent(
|
||
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
|
||
);
|
||
}
|
||
if (payload.agentLevelCounts) {
|
||
agentLevelCounts.value = payload.agentLevelCounts;
|
||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||
loadSubAgentsAtLevel(lvl);
|
||
}
|
||
}
|
||
settingsLoaded.value = true;
|
||
} catch {
|
||
/* keep defaults */
|
||
}
|
||
}
|
||
|
||
watch(settingsCollapseOpen, (open) => {
|
||
if (!open.includes('settings')) return;
|
||
if (!resetDbStatusLoaded.value) {
|
||
resetDbStatusLoaded.value = true;
|
||
void loadResetDatabaseStatus();
|
||
}
|
||
if (!settingsLoaded.value) {
|
||
void loadUsersPageInit();
|
||
}
|
||
});
|
||
|
||
/* ─── Load tier-1 agents ─── */
|
||
async function loadTier1Agents() {
|
||
try {
|
||
const { data } = await api.get('/admin/agents', {
|
||
params: {
|
||
page: tier1Page.value,
|
||
pageSize: tier1PageSize.value,
|
||
keyword: tier1Keyword.value.trim() || undefined,
|
||
status: tier1FilterStatus.value || undefined,
|
||
level: 1,
|
||
},
|
||
});
|
||
tier1Agents.value = data.data.items as AgentRow[];
|
||
tier1Total.value = data.data.total;
|
||
} catch (e) {
|
||
tier1Agents.value = [];
|
||
tier1Total.value = 0;
|
||
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
|
||
}
|
||
}
|
||
|
||
function onTier1PageChange(p: number) {
|
||
tier1Page.value = p;
|
||
loadTier1Agents();
|
||
}
|
||
|
||
function onTier1SizeChange(size: number) {
|
||
tier1PageSize.value = size;
|
||
tier1Page.value = 1;
|
||
loadTier1Agents();
|
||
}
|
||
|
||
function searchTier1Agents() {
|
||
tier1Page.value = 1;
|
||
loadTier1Agents();
|
||
}
|
||
|
||
async function loadAgentLevelCounts() {
|
||
try {
|
||
const { data } = await api.get('/admin/agents/level-counts');
|
||
const raw = data.data ?? {};
|
||
const normalized: Record<number, number> = {};
|
||
for (const [lvl, cnt] of Object.entries(raw)) {
|
||
normalized[Number(lvl)] = Number(cnt) || 0;
|
||
}
|
||
agentLevelCounts.value = normalized;
|
||
} catch {
|
||
agentLevelCounts.value = {};
|
||
}
|
||
}
|
||
|
||
async function loadSubAgentsAtLevel(level: number) {
|
||
const st = ensureSubAgentState(level);
|
||
try {
|
||
const { data } = await api.get('/admin/agents', {
|
||
params: {
|
||
page: st.page,
|
||
pageSize: st.pageSize,
|
||
keyword: st.keyword.trim() || undefined,
|
||
status: st.filterStatus || undefined,
|
||
level,
|
||
},
|
||
});
|
||
st.agents = data.data.items as AgentRow[];
|
||
st.total = data.data.total;
|
||
} catch (e) {
|
||
st.agents = [];
|
||
st.total = 0;
|
||
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
|
||
}
|
||
}
|
||
|
||
function onSubAgentPageChange(level: number, p: number) {
|
||
ensureSubAgentState(level).page = p;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
function onSubAgentSizeChange(level: number, size: number) {
|
||
const st = ensureSubAgentState(level);
|
||
st.pageSize = size;
|
||
st.page = 1;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
function bindSubAgentPageChange(level: number) {
|
||
return (p: number) => onSubAgentPageChange(level, p);
|
||
}
|
||
|
||
function bindSubAgentSizeChange(level: number) {
|
||
return (size: number) => onSubAgentSizeChange(level, size);
|
||
}
|
||
|
||
function searchSubAgentsAtLevel(level: number) {
|
||
ensureSubAgentState(level).page = 1;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
function reloadSubAgentTabs() {
|
||
return loadAgentLevelCounts().then(() => {
|
||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||
loadSubAgentsAtLevel(lvl);
|
||
}
|
||
});
|
||
}
|
||
|
||
function reloadAgentLists() {
|
||
loadTier1Agents();
|
||
void reloadSubAgentTabs();
|
||
}
|
||
|
||
/* ─── Load main agent list ─── */
|
||
async function load() {
|
||
reloadAgentLists();
|
||
}
|
||
|
||
async function loadAgentOptions(keyword = '') {
|
||
agentOptionsLoading.value = true;
|
||
try {
|
||
const { data } = await api.get('/admin/agents/options', {
|
||
params: {
|
||
keyword: keyword.trim() || undefined,
|
||
limit: 50,
|
||
},
|
||
});
|
||
agentOptions.value = data.data;
|
||
} catch {
|
||
agentOptions.value = [];
|
||
} finally {
|
||
agentOptionsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onAgentOptionsSearch(keyword: string) {
|
||
void loadAgentOptions(keyword);
|
||
}
|
||
|
||
async function loadAllPlayers() {
|
||
playerLoading.value = true;
|
||
try {
|
||
const params: Record<string, unknown> = {
|
||
page: playerPage.value,
|
||
pageSize: playerPageSize.value,
|
||
keyword: playerKeyword.value.trim() || undefined,
|
||
status: playerFilterStatus.value || undefined,
|
||
};
|
||
if (playerFilterAgent.value) {
|
||
params.parentId = playerFilterAgent.value;
|
||
}
|
||
const { data } = await api.get('/admin/users', { params });
|
||
allPlayers.value = data.data.items as PlayerRow[];
|
||
playerTotal.value = data.data.total;
|
||
} catch {
|
||
allPlayers.value = [];
|
||
playerTotal.value = 0;
|
||
} finally {
|
||
playerLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function searchPlayers() {
|
||
playerPage.value = 1;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
function onPlayerPageChange(p: number) {
|
||
playerPage.value = p;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
function onPlayerSizeChange(size: number) {
|
||
playerPageSize.value = size;
|
||
playerPage.value = 1;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
function affiliationLabel(row: Pick<PlayerRow, 'affiliationAgents'>) {
|
||
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
|
||
}
|
||
|
||
function directPlayersTabLabel(ownerName: string, count: number) {
|
||
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
|
||
}
|
||
|
||
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
|
||
onAgentRowClick(row, event);
|
||
}
|
||
|
||
function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
|
||
onAgentRowClick(row, event);
|
||
}
|
||
|
||
watch(activeViewTab, (tab) => {
|
||
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||
if (m) loadSubAgentsAtLevel(Number(m[1]));
|
||
});
|
||
|
||
watch(visibleSubAgentTabLevels, (levels, prev) => {
|
||
for (const lvl of levels) {
|
||
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
|
||
loadSubAgentsAtLevel(lvl);
|
||
}
|
||
}
|
||
});
|
||
|
||
/* ─── Expansion ─── */
|
||
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
|
||
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
|
||
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
|
||
await loadExpansionData(row.userId);
|
||
}
|
||
}
|
||
|
||
function onAgentRowClick(row: AgentRow, 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(agentId: string) {
|
||
expandLoading.value[agentId] = true;
|
||
try {
|
||
const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } });
|
||
agentPlayersMap.value[agentId] = data.data.items as PlayerRow[];
|
||
} catch {
|
||
agentPlayersMap.value[agentId] = [];
|
||
} finally {
|
||
expandLoading.value[agentId] = false;
|
||
}
|
||
}
|
||
|
||
function getPlayers(agentId: string) {
|
||
return agentPlayersMap.value[agentId] || [];
|
||
}
|
||
|
||
function refreshExpandedAgentPlayers() {
|
||
for (const agentId of expandedSet.value) {
|
||
loadExpansionData(agentId);
|
||
}
|
||
}
|
||
|
||
/* ─── 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 loadHierarchySettings() {
|
||
try {
|
||
const { data } = await api.get('/admin/agents/settings/hierarchy');
|
||
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
|
||
} catch {
|
||
hierarchySettings.value = { maxAgentLevel: 0 };
|
||
}
|
||
}
|
||
|
||
async function saveHierarchySettings() {
|
||
hierarchySaving.value = true;
|
||
try {
|
||
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
|
||
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
|
||
ElMessage.success(t('msg.saved'));
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
loadHierarchySettings();
|
||
} finally {
|
||
hierarchySaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadPlatformDirectSettings() {
|
||
try {
|
||
const { data } = await api.get('/admin/settings/cashback/platform-direct');
|
||
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
|
||
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
|
||
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
|
||
} catch {
|
||
platformDirectRate.value = 0;
|
||
adminInviteRate.value = 0;
|
||
}
|
||
}
|
||
|
||
async function savePlatformDirectSettings() {
|
||
platformDirectSaving.value = true;
|
||
try {
|
||
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
|
||
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
|
||
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
|
||
});
|
||
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
|
||
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
|
||
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
|
||
ElMessage.success(t('msg.saved'));
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
loadPlatformDirectSettings();
|
||
} finally {
|
||
platformDirectSaving.value = false;
|
||
}
|
||
}
|
||
|
||
const walletLedgerVisible = ref(false);
|
||
const walletLedgerPlayerId = ref('');
|
||
const walletLedgerPlayerUsername = ref<string | null>(null);
|
||
|
||
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
|
||
walletLedgerPlayerId.value = playerId;
|
||
walletLedgerPlayerUsername.value = playerUsername ?? null;
|
||
walletLedgerVisible.value = true;
|
||
}
|
||
|
||
/* ─── Create (unified) ─── */
|
||
function openCreateAccount() {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createForm.value.asTier1Agent = false;
|
||
createParentAgentId.value = '';
|
||
createParentLocked.value = false;
|
||
createAccountMode.value = 0;
|
||
createVisible.value = true;
|
||
}
|
||
|
||
function openCreateTier1Agent() {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createForm.value.asTier1Agent = true;
|
||
createParentAgentId.value = '';
|
||
createParentLocked.value = false;
|
||
createAccountMode.value = 1;
|
||
createVisible.value = true;
|
||
}
|
||
|
||
function openCreatePlayer(parentAgentUserId: string) {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createForm.value.asTier1Agent = false;
|
||
createForm.value.parentId = parentAgentUserId;
|
||
createParentAgentId.value = parentAgentUserId;
|
||
createParentLocked.value = true;
|
||
createAccountMode.value = 0;
|
||
createVisible.value = true;
|
||
}
|
||
|
||
function openCreateSubAgent(parentAgentUserId: string) {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createForm.value.asTier1Agent = false;
|
||
createParentAgentId.value = parentAgentUserId;
|
||
createParentLocked.value = true;
|
||
const parentLevel = resolveParentAgentLevel(parentAgentUserId);
|
||
createToolbarChildLevel.value = parentLevel != null ? parentLevel + 1 : null;
|
||
createAccountMode.value = 2;
|
||
createVisible.value = true;
|
||
}
|
||
|
||
function openCreateSubAgentFromToolbar(childLevel: number) {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createForm.value.asTier1Agent = false;
|
||
createParentLocked.value = false;
|
||
createToolbarChildLevel.value = childLevel;
|
||
const parents = parentOptionsForChildLevel(childLevel);
|
||
createParentAgentId.value = parents.length === 1 ? parents[0].id : '';
|
||
createAccountMode.value = 2;
|
||
createVisible.value = true;
|
||
}
|
||
|
||
async function submitCreate() {
|
||
const isSubAgent = createAccountMode.value === 2;
|
||
const isAgent = createForm.value.asTier1Agent || isSubAgent;
|
||
let payload: Record<string, unknown>;
|
||
try {
|
||
if (isSubAgent) {
|
||
if (!createParentAgentId.value) {
|
||
throw new Error(t('agent.hint.select_parent_for_level', { level: lvlLabel((createToolbarChildLevel.value ?? 2) - 1) }));
|
||
}
|
||
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
|
||
const targetLevel = createTargetAgentLevel.value;
|
||
if (targetLevel == null || parentLevel == null || parentLevel !== targetLevel - 1) {
|
||
throw new Error(t('agent.err.parent_level_mismatch', { level: lvlLabel(targetLevel ?? 0) }));
|
||
}
|
||
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
|
||
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
|
||
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
||
if (createForm.value.creditLimit > createSubCreditMax.value) {
|
||
throw new Error(t('err.insufficient_credit'));
|
||
}
|
||
payload = {
|
||
username: createForm.value.username.trim(),
|
||
password: createForm.value.password,
|
||
asSubAgent: true,
|
||
parentAgentId: createParentAgentId.value,
|
||
phone: createForm.value.phone.trim() || undefined,
|
||
email: createForm.value.email.trim() || undefined,
|
||
creditLimit: createForm.value.creditLimit,
|
||
cashbackRate: percentToDecimalRate(createForm.value.cashbackRate),
|
||
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
|
||
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
|
||
};
|
||
} else {
|
||
payload = buildCreatePlayerPayload(createForm.value);
|
||
// When creating player from expansion, ensure parentId is set
|
||
if (createParentAgentId.value && !payload.parentId) {
|
||
payload.parentId = createParentAgentId.value;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
ElMessage.warning(resolveFormError(e, t));
|
||
return;
|
||
}
|
||
createLoading.value = true;
|
||
try {
|
||
await api.post('/admin/users', payload);
|
||
ElMessage.success(
|
||
isAgent
|
||
? t('msg.agent_created')
|
||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||
);
|
||
createVisible.value = false;
|
||
const createdLevel = isSubAgent ? createTargetAgentLevel.value : null;
|
||
load();
|
||
refreshExpandedParents();
|
||
if (createdLevel != null && createdLevel >= 2) {
|
||
activeViewTab.value = agentLevelTabName(createdLevel);
|
||
}
|
||
const parentId = createParentAgentId.value || createForm.value.parentId;
|
||
if (parentId) {
|
||
await loadExpansionData(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;
|
||
}
|
||
try {
|
||
assertPlayerUsername(editPlayerForm.value.username);
|
||
} catch (e) {
|
||
ElMessage.warning(resolveFormError(e, t));
|
||
return;
|
||
}
|
||
editPlayerLoading.value = true;
|
||
try {
|
||
const newPwd = editPlayerForm.value.newPassword.trim();
|
||
const payload: Record<string, unknown> = {
|
||
username: editPlayerForm.value.username.trim(),
|
||
phone: editPlayerForm.value.phone.trim() || undefined,
|
||
email: editPlayerForm.value.email.trim() || undefined,
|
||
password: newPwd || undefined,
|
||
cashbackRate: editPlayerForm.value.useCustomCashback
|
||
? percentToDecimalRate(editPlayerForm.value.customCashbackRate ?? 0)
|
||
: null,
|
||
};
|
||
const { data } = await api.put(`/admin/users/${editingId.value}`, payload);
|
||
const updated = data.data as PlayerDetail;
|
||
if (newPwd) {
|
||
editPlayerForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||
editPlayerForm.value.newPassword = '';
|
||
ElMessage.success(t('user.msg.password_saved', { password: editPlayerForm.value.managedPassword }));
|
||
return;
|
||
}
|
||
ElMessage.success(t('msg.saved'));
|
||
editPlayerVisible.value = false;
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
} finally {
|
||
editPlayerLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/* ─── Edit Agent ─── */
|
||
async function openEditAgent(userId: string) {
|
||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||
const d = data.data as AgentDetail;
|
||
editingId.value = userId;
|
||
editAgentForm.value = editFormFromAgentDetail(d);
|
||
editAgentVisible.value = true;
|
||
}
|
||
|
||
async function submitEditAgent() {
|
||
if (editAgentForm.value.newPassword && editAgentForm.value.newPassword.length < 8) {
|
||
ElMessage.warning(t('err.password_min'));
|
||
return;
|
||
}
|
||
editAgentLoading.value = true;
|
||
try {
|
||
const newPwd = editAgentForm.value.newPassword.trim();
|
||
const { data } = await api.put(`/admin/agents/${editingId.value}`, {
|
||
username: editAgentForm.value.username.trim(),
|
||
status: editAgentForm.value.status,
|
||
phone: editAgentForm.value.phone.trim() || undefined,
|
||
email: editAgentForm.value.email.trim() || undefined,
|
||
cashbackRate: percentToDecimalRate(editAgentForm.value.cashbackRate),
|
||
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
|
||
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
|
||
password: newPwd || undefined,
|
||
});
|
||
const updated = data.data as AgentDetail;
|
||
if (newPwd) {
|
||
editAgentForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||
editAgentForm.value.newPassword = '';
|
||
ElMessage.success(t('user.msg.password_saved', { password: editAgentForm.value.managedPassword }));
|
||
return;
|
||
}
|
||
ElMessage.success(t('msg.saved'));
|
||
editAgentVisible.value = false;
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
} finally {
|
||
editAgentLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/* ─── Detail ─── */
|
||
async function openDetailPlayer(id: string) {
|
||
const { data } = await api.get(`/admin/users/${id}`);
|
||
playerDetail.value = data.data as PlayerDetail;
|
||
detailPlayerVisible.value = true;
|
||
}
|
||
|
||
async function openDetailAgent(userId: string) {
|
||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||
agentDetail.value = data.data as AgentDetail;
|
||
detailAgentVisible.value = true;
|
||
}
|
||
|
||
/* ─── Credit ─── */
|
||
async function openCredit(userId: string) {
|
||
editingId.value = userId;
|
||
creditForm.value = { amount: 10000, remark: '' };
|
||
creditContext.value = null;
|
||
creditVisible.value = true;
|
||
creditContextLoading.value = true;
|
||
try {
|
||
creditContext.value = await fetchAdminAgentCreditContext(userId);
|
||
} catch (e: unknown) {
|
||
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
|
||
creditVisible.value = false;
|
||
} finally {
|
||
creditContextLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitCredit() {
|
||
if (creditForm.value.amount === 0) {
|
||
ElMessage.warning(t('msg.credit_zero'));
|
||
return;
|
||
}
|
||
const maxInc = maxCreditIncreaseAmount(creditContext.value);
|
||
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
|
||
ElMessage.warning(t('err.insufficient_credit'));
|
||
return;
|
||
}
|
||
creditLoading.value = true;
|
||
try {
|
||
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
||
amount: creditForm.value.amount,
|
||
requestId: `credit-${editingId.value}-${Date.now()}`,
|
||
remark: creditForm.value.remark || undefined,
|
||
});
|
||
ElMessage.success(t('msg.credit_adjusted'));
|
||
creditVisible.value = false;
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
|
||
} finally {
|
||
creditLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/* ─── Freeze / Unfreeze ─── */
|
||
async function toggleFreezePlayer(row: PlayerRow) {
|
||
const freeze = row.status === 'ACTIVE';
|
||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
t('msg.freeze_confirm_body', {
|
||
action,
|
||
name: row.username,
|
||
extra: freeze ? t('msg.freeze_extra') : '',
|
||
}),
|
||
t('msg.freeze_confirm_title', { action }),
|
||
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
await api.put(`/admin/users/${row.id}`, {
|
||
status: freeze ? 'SUSPENDED' : 'ACTIVE',
|
||
});
|
||
ElMessage.success(t('msg.freeze_done', { action }));
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||
}
|
||
}
|
||
|
||
/* ─── Delete Player ─── */
|
||
async function deletePlayer(row: PlayerRow) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
t('msg.delete_player_body', { name: row.username }),
|
||
t('msg.delete_player_title'),
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: t('common.delete'),
|
||
cancelButtonText: t('common.cancel'),
|
||
},
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
// Second confirmation — type username to confirm
|
||
const input = ref('');
|
||
try {
|
||
await ElMessageBox({
|
||
title: t('msg.delete_player_confirm_title'),
|
||
message: () =>
|
||
h('div', {}, [
|
||
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
|
||
t('msg.delete_player_confirm_hint', { name: row.username })),
|
||
h('input', {
|
||
value: input.value,
|
||
placeholder: row.username,
|
||
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
|
||
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
|
||
}),
|
||
]),
|
||
showCancelButton: true,
|
||
confirmButtonText: t('common.delete'),
|
||
cancelButtonText: t('common.cancel'),
|
||
beforeClose: (action: string, _instance: unknown, done: () => void) => {
|
||
if (action === 'confirm') {
|
||
if (input.value.trim() !== row.username) {
|
||
ElMessage.warning(t('msg.delete_player_mismatch'));
|
||
return;
|
||
}
|
||
}
|
||
done();
|
||
},
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
await api.delete(`/admin/users/${row.id}`);
|
||
ElMessage.success(t('msg.delete_player_done'));
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
|
||
}
|
||
}
|
||
|
||
/* ─── Freeze / Unfreeze Agent ─── */
|
||
const freezeAgentIsSuspend = computed(() => {
|
||
if (!freezeAgentTarget.value) return true;
|
||
return subAgentAccountStatus(freezeAgentTarget.value) === 'ACTIVE';
|
||
});
|
||
|
||
function toggleFreezeAgent(row: AgentRow) {
|
||
freezeAgentTarget.value = row;
|
||
freezeAgentForm.value = {
|
||
freezeDirectPlayers: false,
|
||
blockDirectPlayerLogin: false,
|
||
unfreezeDirectPlayers: false,
|
||
};
|
||
freezeAgentVisible.value = true;
|
||
}
|
||
|
||
async function submitFreezeAgent() {
|
||
const row = freezeAgentTarget.value;
|
||
if (!row) return;
|
||
const suspend = freezeAgentIsSuspend.value;
|
||
const action = suspend ? t('common.freeze') : t('common.unfreeze');
|
||
freezeAgentLoading.value = true;
|
||
try {
|
||
if (suspend) {
|
||
await api.put(`/admin/agents/${row.userId}`, {
|
||
status: 'SUSPENDED',
|
||
freezeDirectPlayers: freezeAgentForm.value.freezeDirectPlayers,
|
||
blockDirectPlayerLogin: freezeAgentForm.value.blockDirectPlayerLogin,
|
||
});
|
||
ElMessage.success(
|
||
freezeAgentForm.value.freezeDirectPlayers
|
||
? t('agent.msg.cascade_freeze_done')
|
||
: t('agent.msg.freeze_done', { action }),
|
||
);
|
||
} else {
|
||
await api.put(`/admin/agents/${row.userId}`, {
|
||
status: 'ACTIVE',
|
||
unfreezeDirectPlayers: freezeAgentForm.value.unfreezeDirectPlayers,
|
||
});
|
||
ElMessage.success(
|
||
freezeAgentForm.value.unfreezeDirectPlayers
|
||
? t('agent.msg.cascade_unfreeze_done')
|
||
: t('agent.msg.freeze_done', { action }),
|
||
);
|
||
}
|
||
freezeAgentVisible.value = false;
|
||
load();
|
||
refreshExpandedParents();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||
} finally {
|
||
freezeAgentLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/* ─── Helpers ─── */
|
||
function refreshExpandedParents() {
|
||
loadAllPlayers();
|
||
reloadAgentLists();
|
||
refreshExpandedAgentPlayers();
|
||
}
|
||
|
||
const {
|
||
transferVisible,
|
||
transferLoading,
|
||
transferType,
|
||
transferTarget,
|
||
transferAmount,
|
||
transferRemark,
|
||
transferContext,
|
||
transferContextLoading,
|
||
transferAmountRange,
|
||
transferAmountDisabled,
|
||
transferAmountExceedsCap,
|
||
transferAmountCapError,
|
||
transferTitle,
|
||
openTransfer,
|
||
submitTransfer,
|
||
} = useAdminPlayerTransfer(async () => {
|
||
load();
|
||
refreshExpandedParents();
|
||
});
|
||
|
||
function creditLine(row: AgentRow) {
|
||
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
|
||
}
|
||
|
||
function creditLineFull(row: AgentRow) {
|
||
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
|
||
}
|
||
|
||
function formatTime(v: string) {
|
||
if (!v) return '—';
|
||
return new Date(v).toLocaleString(localeTag.value);
|
||
}
|
||
|
||
function formatLastLogin(v: string | null) {
|
||
if (!v) return t('common.never_login');
|
||
const d = new Date(v);
|
||
const now = new Date();
|
||
const sameDay =
|
||
d.getFullYear() === now.getFullYear() &&
|
||
d.getMonth() === now.getMonth() &&
|
||
d.getDate() === now.getDate();
|
||
if (sameDay) {
|
||
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
return d.toLocaleString(localeTag.value, {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function statusTagType(s: string) {
|
||
return s === 'ACTIVE' ? 'success' : 'warning';
|
||
}
|
||
|
||
function statusLabel(s: string) {
|
||
const key = `user.status.${s}`;
|
||
const v = t(key);
|
||
return v !== key ? v : s;
|
||
}
|
||
|
||
function creditTypeLabel(type: string) {
|
||
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
|
||
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
|
||
return type;
|
||
}
|
||
</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.hierarchy.settings_title') }}</p>
|
||
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
|
||
<el-form inline size="small" class="settings-form">
|
||
<el-form-item :label="t('agent.hierarchy.max_level')">
|
||
<el-input-number
|
||
v-model="hierarchySettings.maxAgentLevel"
|
||
:min="0"
|
||
:step="1"
|
||
controls-position="right"
|
||
:disabled="hierarchySaving"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<div class="list-settings-block">
|
||
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
|
||
<el-form inline size="small" class="settings-form">
|
||
<el-form-item :label="t('cashback.platform_direct_default_rate')">
|
||
<RatePercentInput v-model="platformDirectRate" />
|
||
</el-form-item>
|
||
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
|
||
<el-form-item :label="t('cashback.admin_invite_default_rate')">
|
||
<RatePercentInput v-model="adminInviteRate" />
|
||
</el-form-item>
|
||
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">{{ t('common.save') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<div class="list-settings-block">
|
||
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
|
||
<el-form inline size="small" class="settings-form limits-form">
|
||
<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>
|
||
|
||
<InviteManageDialog v-model="inviteDialogOpen" />
|
||
|
||
<div class="mgr-tabs-shell">
|
||
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
|
||
{{ t('invite.menu_btn') }}
|
||
</el-button>
|
||
<el-tabs v-model="activeViewTab" class="mgr-top-tabs mgr-top-tabs--with-invite">
|
||
<!-- ─── Tab: 全部玩家(默认) ─── -->
|
||
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
|
||
<section class="list-panel player-list-panel">
|
||
<div class="list-panel-toolbar">
|
||
<el-form inline class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input
|
||
v-model="playerKeyword"
|
||
:placeholder="t('user.filter.username_ph')"
|
||
clearable
|
||
style="width: 180px"
|
||
@keyup.enter="searchPlayers"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.filter.agent')">
|
||
<el-select
|
||
v-model="playerFilterAgent"
|
||
:placeholder="t('user.filter.agent_ph')"
|
||
clearable
|
||
filterable
|
||
remote
|
||
reserve-keyword
|
||
:remote-method="onAgentOptionsSearch"
|
||
:loading="agentOptionsLoading"
|
||
style="width: 200px"
|
||
@focus="() => { if (!agentOptions.length) void loadAgentOptions(); }"
|
||
>
|
||
<el-option
|
||
v-for="a in agentOptions"
|
||
:key="a.id"
|
||
:label="agentOptionLabel(a)"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select v-model="playerFilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="searchPlayers">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="list-chrome__actions">
|
||
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
|
||
</div>
|
||
</div>
|
||
<AdminTableWrap>
|
||
<el-table v-loading="playerLoading" :data="allPlayers" stripe>
|
||
<template #empty>
|
||
<AdminTableEmpty />
|
||
</template>
|
||
<el-table-column prop="id" 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 :label="t('user.col.agent')" min-width="200">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.invite_code')" min-width="100" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`" placement="top">
|
||
<span class="amount-compact">{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
|
||
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
|
||
<template #default="{ row }">
|
||
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.actions')" min-width="340" align="center">
|
||
<template #default="{ row }">
|
||
<AdminPlayerRowActions
|
||
:row="row"
|
||
@detail="openDetailPlayer(row.id)"
|
||
@ledger="openPlayerWalletLedger(row.id, row.username)"
|
||
@edit="openEditPlayer(row.id)"
|
||
@deposit="openTransfer('deposit', row)"
|
||
@withdraw="openTransfer('withdraw', row)"
|
||
@freeze="toggleFreezePlayer(row)"
|
||
@delete="deletePlayer(row)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</AdminTableWrap>
|
||
<div class="pager">
|
||
<el-pagination
|
||
v-model:current-page="playerPage"
|
||
v-model:page-size="playerPageSize"
|
||
:total="playerTotal"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
@current-change="onPlayerPageChange"
|
||
@size-change="onPlayerSizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</el-tab-pane>
|
||
|
||
<!-- ─── Tab: 一级代理 ─── -->
|
||
<el-tab-pane :label="`${agentTierName(1)} (${tier1Total})`" name="tier1Agents">
|
||
<section class="list-panel agent-list-panel">
|
||
<div class="list-panel-toolbar">
|
||
<el-form inline class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input v-model="tier1Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier1Agents" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select v-model="tier1FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="searchTier1Agents">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="list-chrome__actions">
|
||
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
|
||
</div>
|
||
</div>
|
||
<AdminTableWrap>
|
||
<el-table
|
||
:data="tier1Agents"
|
||
stripe
|
||
row-key="userId"
|
||
:expand-row-keys="expandedRowKeys"
|
||
:row-class-name="expandableTableRowClassName"
|
||
class="expandable-table compact-agent-table"
|
||
@expand-change="onExpandChange"
|
||
@row-click="onTier1AgentRowClick"
|
||
>
|
||
<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>
|
||
<div v-else class="expand-panel-body">
|
||
<div class="expand-section-header">
|
||
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
|
||
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
|
||
</div>
|
||
<el-table :data="getPlayers(row.userId)" stripe 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('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||
<template #default="{ row: player }">
|
||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.status')" width="80">
|
||
<template #default="{ row: player }">
|
||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||
</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('common.actions')" min-width="320" align="center">
|
||
<template #default="{ row: player }">
|
||
<AdminPlayerRowActions
|
||
:row="player"
|
||
@detail="openDetailPlayer(player.id)"
|
||
@ledger="openPlayerWalletLedger(player.id, player.username)"
|
||
@edit="openEditPlayer(player.id)"
|
||
@deposit="openTransfer('deposit', player)"
|
||
@withdraw="openTransfer('withdraw', player)"
|
||
@freeze="toggleFreezePlayer(player)"
|
||
@delete="deletePlayer(player)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="userId" label="ID" min-width="64" />
|
||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||
<el-table-column :label="t('common.status')" min-width="72">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="level" :label="t('agent.col.level')" min-width="52" align="center">
|
||
<template #default="{ row }">L{{ row.level }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('agent.col.credit')" min-width="148" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
|
||
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" min-width="72" align="center" />
|
||
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
|
||
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.actions')" min-width="300" align="center">
|
||
<template #default="{ row }">
|
||
<AdminAgentRowActions
|
||
:row="{ userId: row.userId, status: row.status }"
|
||
:can-create-sub="canAgentCreateSub(row)"
|
||
:create-sub-label="childAgentActionLabel(row.level)"
|
||
@detail="openDetailAgent(row.userId)"
|
||
@edit="openEditAgent(row.userId)"
|
||
@credit="openCredit(row.userId)"
|
||
@create-sub="openCreateSubAgent(row.userId)"
|
||
@freeze="toggleFreezeAgent(row)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</AdminTableWrap>
|
||
<div class="pager">
|
||
<el-pagination
|
||
v-model:current-page="tier1Page"
|
||
v-model:page-size="tier1PageSize"
|
||
:total="tier1Total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
@current-change="onTier1PageChange"
|
||
@size-change="onTier1SizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</el-tab-pane>
|
||
|
||
<!-- ─── Tab: L2+ 各级代理 ─── -->
|
||
<el-tab-pane
|
||
v-for="agentLevel in visibleSubAgentTabLevels"
|
||
:key="agentLevel"
|
||
:label="agentLevelTabLabel(agentLevel)"
|
||
:name="agentLevelTabName(agentLevel)"
|
||
>
|
||
<section class="list-panel agent-list-panel">
|
||
<div class="list-panel-toolbar">
|
||
<el-form inline class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input
|
||
v-model="ensureSubAgentState(agentLevel).keyword"
|
||
:placeholder="t('agent.filter.username_ph')"
|
||
clearable
|
||
style="width: 180px"
|
||
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select
|
||
v-model="ensureSubAgentState(agentLevel).filterStatus"
|
||
:placeholder="t('common.all')"
|
||
clearable
|
||
style="width: 120px"
|
||
>
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="list-chrome__actions">
|
||
<el-button type="primary" @click="openCreateSubAgentFromToolbar(agentLevel)">
|
||
{{ agentLevel === 2 ? t('agent.create_sub_btn') : t('agent.create_level_agent_btn', { level: lvlLabel(agentLevel) }) }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<AdminTableWrap>
|
||
<el-table
|
||
:data="ensureSubAgentState(agentLevel).agents"
|
||
stripe
|
||
row-key="userId"
|
||
:expand-row-keys="expandedRowKeys"
|
||
:row-class-name="expandableTableRowClassName"
|
||
class="expandable-table compact-agent-table"
|
||
@expand-change="onExpandChange"
|
||
@row-click="onSubAgentRowClick"
|
||
>
|
||
<template #empty>
|
||
<AdminTableEmpty />
|
||
</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">
|
||
<div class="expand-section-header">
|
||
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
|
||
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
|
||
</div>
|
||
<el-table :data="getPlayers(row.userId)" stripe 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('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||
<template #default="{ row: player }">
|
||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.status')" width="80">
|
||
<template #default="{ row: player }">
|
||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||
<template #default="{ row: player }">
|
||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.actions')" min-width="280" align="center">
|
||
<template #default="{ row: player }">
|
||
<AdminPlayerRowActions
|
||
:row="player"
|
||
@detail="openDetailPlayer(player.id)"
|
||
@ledger="openPlayerWalletLedger(player.id, player.username)"
|
||
@edit="openEditPlayer(player.id)"
|
||
@deposit="openTransfer('deposit', player)"
|
||
@withdraw="openTransfer('withdraw', player)"
|
||
@freeze="toggleFreezePlayer(player)"
|
||
@delete="deletePlayer(player)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="userId" label="ID" min-width="64" />
|
||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.status')" min-width="72">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('agent.col.credit')" min-width="148" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
|
||
<el-table-column :label="t('common.actions')" min-width="300" align="center">
|
||
<template #default="{ row }">
|
||
<AdminAgentRowActions
|
||
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
|
||
:can-create-sub="canAgentCreateSub(row)"
|
||
:create-sub-label="childAgentActionLabel(row.level)"
|
||
@detail="openDetailAgent(row.userId)"
|
||
@edit="openEditAgent(row.userId)"
|
||
@credit="openCredit(row.userId)"
|
||
@create-sub="openCreateSubAgent(row.userId)"
|
||
@freeze="toggleFreezeAgent(row)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</AdminTableWrap>
|
||
<div class="pager">
|
||
<el-pagination
|
||
:current-page="ensureSubAgentState(agentLevel).page"
|
||
:page-size="ensureSubAgentState(agentLevel).pageSize"
|
||
:total="ensureSubAgentState(agentLevel).total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
@current-change="bindSubAgentPageChange(agentLevel)"
|
||
@size-change="bindSubAgentSizeChange(agentLevel)"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- ═══════════ DIALOGS ═══════════ -->
|
||
|
||
<!-- ── Freeze / Unfreeze Agent ── -->
|
||
<el-dialog
|
||
v-model="freezeAgentVisible"
|
||
:title="freezeAgentIsSuspend ? t('agent.freeze.confirm_freeze_title') : t('agent.unfreeze.confirm_title')"
|
||
width="480px"
|
||
destroy-on-close
|
||
>
|
||
<template v-if="freezeAgentTarget">
|
||
<p class="freeze-agent-intro">
|
||
{{
|
||
freezeAgentIsSuspend
|
||
? t('agent.freeze.confirm_freeze_body', { name: freezeAgentTarget.username })
|
||
: t('agent.freeze.confirm_unfreeze_body', { name: freezeAgentTarget.username })
|
||
}}
|
||
</p>
|
||
<div v-if="freezeAgentIsSuspend" class="freeze-agent-options">
|
||
<el-checkbox
|
||
v-if="freezeAgentTarget.directPlayerCount > 0"
|
||
v-model="freezeAgentForm.freezeDirectPlayers"
|
||
>
|
||
{{ t('agent.freeze.opt_freeze_direct_players') }}
|
||
</el-checkbox>
|
||
<el-checkbox v-model="freezeAgentForm.blockDirectPlayerLogin">
|
||
{{ t('agent.freeze.opt_block_player_login') }}
|
||
</el-checkbox>
|
||
</div>
|
||
<div v-else class="freeze-agent-options">
|
||
<el-checkbox
|
||
v-if="freezeAgentTarget.directPlayerCount > 0"
|
||
v-model="freezeAgentForm.unfreezeDirectPlayers"
|
||
>
|
||
{{ t('agent.unfreeze.opt_unfreeze_direct_players') }}
|
||
</el-checkbox>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<el-button @click="freezeAgentVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button
|
||
:type="freezeAgentIsSuspend ? 'warning' : 'primary'"
|
||
:loading="freezeAgentLoading"
|
||
@click="submitFreezeAgent"
|
||
>
|
||
{{ freezeAgentIsSuspend ? t('common.freeze') : t('common.unfreeze') }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Create (unified) ── -->
|
||
<el-dialog
|
||
v-model="createVisible"
|
||
:title="createDialogTitle"
|
||
width="460px"
|
||
align-center
|
||
destroy-on-close
|
||
class="create-account-dialog"
|
||
>
|
||
<div
|
||
v-if="createAccountMode === 2 && createParentAgentId && createParentLocked"
|
||
class="create-meta-bar"
|
||
>
|
||
<div class="create-meta-row">
|
||
<span class="create-meta-label">{{ t('agent.field.parent_agent') }}</span>
|
||
<span class="create-meta-value">{{ resolveCreateParentLabel(createParentAgentId) }}</span>
|
||
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
|
||
</div>
|
||
<div class="create-meta-row">
|
||
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
|
||
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-form label-width="100px" size="small" class="compact-create-form">
|
||
<template v-if="createAccountMode === 2 && !createParentLocked">
|
||
<el-form-item :label="t('agent.field.parent_agent')" required>
|
||
<el-select
|
||
v-model="createParentAgentId"
|
||
:placeholder="createParentSelectPlaceholder"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="a in createParentSelectOptions"
|
||
:key="a.id"
|
||
:label="agentOptionLabel(a)"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
<div v-if="createTargetAgentLevel" class="field-hint">
|
||
{{ t('agent.hierarchy.create_level_hint', { n: lvlLabel(createTargetAgentLevel) }) }}
|
||
· {{ t('agent.hint.select_parent_for_level', { level: lvlLabel(createTargetAgentLevel - 1) }) }}
|
||
</div>
|
||
</el-form-item>
|
||
<div v-if="createParentAgentId" class="create-meta-bar create-meta-bar--inline">
|
||
<div class="create-meta-row">
|
||
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
|
||
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
|
||
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<el-form-item :label="t('user.col.username')" required>
|
||
<el-input
|
||
v-model="createForm.username"
|
||
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
|
||
/>
|
||
</el-form-item>
|
||
<div class="create-form-pair">
|
||
<el-form-item :label="t('user.field.password')" required>
|
||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.confirm_password')" required>
|
||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
|
||
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
||
</el-form-item>
|
||
|
||
<el-form-item v-if="createAccountMode === 0 && !createParentLocked" :label="t('user.filter.agent')">
|
||
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
||
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||
<el-input-number
|
||
v-model="createForm.creditLimit"
|
||
:min="0"
|
||
:max="createAccountMode === 2 ? createSubCreditMax : undefined"
|
||
:step="1000"
|
||
style="width: 100%"
|
||
/>
|
||
<div v-if="createAccountMode === 2 && createParentAgentId" class="credit-quick-row">
|
||
<div class="field-hint">
|
||
{{ t('agent.hierarchy.create_credit_quick_hint', { amount: formatAmount(createParentAvailableCredit ?? '0') }) }}
|
||
</div>
|
||
<div class="credit-quick-btns">
|
||
<el-button
|
||
v-for="ratio in creditQuickRatios"
|
||
:key="ratio"
|
||
size="small"
|
||
:disabled="createSubCreditMax <= 0"
|
||
@click="applyCreateSubAgentCreditRatio(ratio)"
|
||
>
|
||
{{ ratio }}%
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||
<RatePercentInput v-model="createForm.cashbackRate" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.max_single_deposit')">
|
||
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
||
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-if="createAccountMode === 0">
|
||
<div class="create-form-pair">
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item :label="t('user.field.initial_balance')">
|
||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item
|
||
v-if="createForm.initialDeposit > 0"
|
||
:label="t('user.field.initial_deposit_kind')"
|
||
>
|
||
<InitialDepositRemarkField
|
||
v-model:kind="createForm.initialDepositRemarkKind"
|
||
v-model:custom="createForm.initialDepositRemarkCustom"
|
||
operator="admin"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="createAccountMode === 1">
|
||
<div class="create-form-pair">
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</div>
|
||
</template>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Edit Player ── -->
|
||
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close class="user-edit-dialog">
|
||
<el-form label-width="84px" size="small" class="compact-edit-form">
|
||
<div class="edit-meta">
|
||
<span>ID {{ editPlayerForm.id }}</span>
|
||
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
|
||
</div>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="section-title">{{ t('user.section.basic_info') }}</div>
|
||
<el-form-item :label="t('user.col.username')">
|
||
<el-input v-model="editPlayerForm.username" :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="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
|
||
<span v-else class="password-empty">—</span>
|
||
</div>
|
||
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
|
||
<div class="password-field-row">
|
||
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
|
||
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="section-title">{{ t('user.section.affiliation') }}</div>
|
||
<el-form-item :label="t('user.col.agent')">
|
||
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
|
||
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||
<div class="cashback-edit-block">
|
||
<el-checkbox v-model="editPlayerForm.useCustomCashback">
|
||
{{ t('cashback.use_custom_rate') }}
|
||
</el-checkbox>
|
||
<RatePercentInput
|
||
v-if="editPlayerForm.useCustomCashback"
|
||
v-model="editPlayerForm.customCashbackRate"
|
||
/>
|
||
<span v-else class="field-hint inline-hint">
|
||
{{ `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }}
|
||
</span>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="section-title">{{ t('user.section.contact') }}</div>
|
||
<el-row :gutter="12" class="contact-row">
|
||
<el-col :span="12">
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<div class="edit-form-section edit-stats-panel">
|
||
<div class="section-title">{{ t('user.section.account_overview') }}</div>
|
||
<AdminDetailGrid :columns="3">
|
||
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.bets_summary')">
|
||
{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
|
||
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
|
||
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
|
||
</AdminDetailItem>
|
||
</AdminDetailGrid>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button size="small" @click="editPlayerVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button size="small" type="primary" :loading="editPlayerLoading" @click="submitEditPlayer">{{ 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')">
|
||
<RatePercentInput v-model="editAgentForm.cashbackRate" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.max_single_deposit')">
|
||
<el-input-number v-model="editAgentForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
||
<el-input-number v-model="editAgentForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="editAgentForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="editAgentForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<el-descriptions :column="2" size="small" border class="edit-stats">
|
||
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ formatAmount(editAgentForm.creditLimit) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editAgentForm.availableCredit) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.used_credit')">{{ formatAmount(editAgentForm.usedCredit) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editAgentForm.directPlayerCount }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ editAgentForm.childAgentCount }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||
{{ editAgentForm.lastLoginAt ? formatTime(editAgentForm.lastLoginAt) : t('common.never_login') }}
|
||
· {{ t('user.login_fail_value', { n: editAgentForm.loginFailCount }) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button size="small" @click="editAgentVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button size="small" type="primary" :loading="editAgentLoading" @click="submitEditAgent">{{ t('user.btn.save_profile') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Player transfer (deposit / withdraw) ── -->
|
||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
|
||
<WalletTransferContext
|
||
:context="transferContext"
|
||
:mode="transferType"
|
||
:loading="transferContextLoading"
|
||
/>
|
||
<el-form label-width="88px">
|
||
<el-form-item :label="t('common.col_id')">
|
||
<span>{{ transferTarget?.id }}</span>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
|
||
<el-input-number
|
||
v-model="transferAmount"
|
||
:min="transferAmountRange.min"
|
||
:disabled="transferAmountDisabled"
|
||
:step="10"
|
||
:precision="2"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.remark')">
|
||
<el-input v-model="transferRemark" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="transferLoading"
|
||
:disabled="transferAmountDisabled || transferAmountExceedsCap"
|
||
@click="submitTransfer"
|
||
>
|
||
{{ t('common.confirm') }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Credit ── -->
|
||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="520px" destroy-on-close>
|
||
<AgentCreditContext
|
||
:context="creditContext"
|
||
:loading="creditContextLoading"
|
||
:adjust-amount="creditForm.amount"
|
||
/>
|
||
<el-form label-width="88px">
|
||
<el-form-item :label="t('agent.field.agent_id')">
|
||
<el-input :model-value="editingId" disabled />
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.adjust_amount')">
|
||
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
|
||
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.remark')">
|
||
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">{{ t('agent.btn.confirm_adjust') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Player Detail ── -->
|
||
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
|
||
<template v-if="playerDetail">
|
||
<AdminDetailGrid :columns="3">
|
||
<AdminDetailItem :label="t('common.col_id')">{{ playerDetail.id }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.col.username')">{{ playerDetail.username }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('common.status')">
|
||
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('agent.field.cashback_rate')">
|
||
{{ formatRatePercent(playerDetail.customCashbackRate ?? playerDetail.defaultCashbackRate) }}
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.available')">
|
||
{{ formatAmount(playerDetail.availableBalance) }}
|
||
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.availableBalance) }})</span>
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.frozen_balance')">
|
||
{{ formatAmount(playerDetail.frozenBalance) }}
|
||
<span v-if="shouldCompact(playerDetail.frozenBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.frozenBalance) }})</span>
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.col.last_login')">
|
||
{{ playerDetail.lastLoginAt ? formatTime(playerDetail.lastLoginAt) : t('common.never_login') }}
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(playerDetail.createdAt) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
|
||
{{ playerDetail.managedPassword ?? '—' }}
|
||
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
|
||
</AdminDetailItem>
|
||
</AdminDetailGrid>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Agent Detail ── -->
|
||
<el-dialog v-model="detailAgentVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
|
||
<template v-if="agentDetail">
|
||
<el-descriptions :column="2" border size="small" class="detail-block">
|
||
<el-descriptions-item :label="t('common.col_id')">{{ agentDetail.userId }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.col.username')">{{ agentDetail.username }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('common.status')">
|
||
<el-tag :type="statusTagType(agentDetail.status)" size="small">{{ statusLabel(agentDetail.status) }}</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.level')">L{{ agentDetail.level }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.credit_limit')">
|
||
{{ formatAmount(agentDetail.creditLimit) }}
|
||
<span v-if="shouldCompact(agentDetail.creditLimit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.creditLimit) }})</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.used_credit')">
|
||
{{ formatAmount(agentDetail.usedCredit) }}
|
||
<span v-if="shouldCompact(agentDetail.usedCredit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.usedCredit) }})</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||
{{ formatAmount(agentDetail.availableCredit) }}
|
||
<span v-if="shouldCompact(agentDetail.availableCredit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.availableCredit) }})</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ agentDetail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ agentDetail.childAgentCount }} {{ t('common.people') }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.player_liability')">{{ formatAmount(agentDetail.directPlayerLiability) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">{{ formatAmount(agentDetail.childAgentExposure) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(agentDetail.cashbackRate) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.phone')">{{ agentDetail.phone ?? '—' }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.email')">{{ agentDetail.email ?? '—' }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||
{{ agentDetail.lastLoginAt ? formatTime(agentDetail.lastLoginAt) : t('common.never_login') }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<InviteCodePanel :invite-code="agentDetail.inviteCode" compact readonly class="detail-invite" />
|
||
|
||
<div class="section-title section-title--row">
|
||
<span>{{ t('agent.section.credit_log') }}</span>
|
||
<el-button
|
||
v-if="agentDetail"
|
||
link
|
||
type="primary"
|
||
@click="router.push({ path: '/finance-logs', query: { tab: 'credit', agentId: agentDetail.userId } })"
|
||
>
|
||
{{ t('agent.credit_tx.view_all') }}
|
||
</el-button>
|
||
</div>
|
||
<el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')">
|
||
<el-table-column :label="t('agent.col.credit_type')" width="80">
|
||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||
<span>{{ formatAmount(row.amount) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
|
||
<span>{{ formatAmount(row.creditAfter) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
|
||
<el-table-column :label="t('audit.col.time')" min-width="150">
|
||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<PlayerWalletLedgerDialog
|
||
v-model="walletLedgerVisible"
|
||
:player-id="walletLedgerPlayerId"
|
||
:player-username="walletLedgerPlayerUsername"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mgr-tabs-shell {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
|
||
padding-right: 108px;
|
||
}
|
||
|
||
.invite-prominent-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
z-index: 2;
|
||
min-width: 96px;
|
||
height: 38px;
|
||
padding: 0 22px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 10px rgba(64, 158, 255, 0.28);
|
||
}
|
||
|
||
.agent-mgr-page > .mgr-tabs-shell {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.mgr-tabs-shell .mgr-top-tabs {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.agent-mgr-page > .agent-list-panel,
|
||
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tabs__content) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tab-pane) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.player-list-panel,
|
||
.agent-list-panel {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.mgr-top-tabs :deep(.el-tabs__header) {
|
||
margin-bottom: 8px;
|
||
}
|
||
.mgr-top-tabs :deep(.el-tabs__item) {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.freeze-agent-intro {
|
||
margin: 0 0 16px;
|
||
line-height: 1.5;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
|
||
.freeze-agent-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* ─── Table toolbar ─── */
|
||
.list-panel-toolbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 10px 0 8px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
--list-chrome-control-h: 32px;
|
||
--el-component-size: 32px;
|
||
}
|
||
.list-panel-toolbar .list-chrome__grow {
|
||
flex: 1 1 280px;
|
||
min-width: 0;
|
||
flex-wrap: wrap;
|
||
row-gap: 8px;
|
||
}
|
||
.list-panel-toolbar .list-chrome__actions {
|
||
flex-shrink: 0;
|
||
}
|
||
.list-panel-toolbar :deep(.el-form-item) {
|
||
margin-bottom: 0 !important;
|
||
margin-right: 12px;
|
||
}
|
||
.list-panel-toolbar :deep(.el-input__wrapper),
|
||
.list-panel-toolbar :deep(.el-select__wrapper) {
|
||
height: var(--list-chrome-control-h) !important;
|
||
min-height: var(--list-chrome-control-h) !important;
|
||
}
|
||
.list-panel-toolbar :deep(.el-button:not(.is-link)) {
|
||
height: var(--list-chrome-control-h) !important;
|
||
min-height: var(--list-chrome-control-h) !important;
|
||
}
|
||
|
||
/* ─── Expansion ─── */
|
||
.expand-panel {
|
||
margin: 4px 0 8px;
|
||
padding: 12px 14px 14px;
|
||
background: #141414;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
}
|
||
.expand-loading {
|
||
text-align: center;
|
||
padding: 16px;
|
||
color: #888;
|
||
font-size: 13px;
|
||
}
|
||
.expand-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
}
|
||
.expand-section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #888;
|
||
}
|
||
.expandable-table :deep(.row-expandable) {
|
||
cursor: pointer;
|
||
}
|
||
.compact-agent-table :deep(.el-table__header .el-table__cell) {
|
||
padding: 4px 8px;
|
||
}
|
||
.compact-agent-table :deep(.el-table__body .el-table__cell) {
|
||
padding: 6px 8px;
|
||
}
|
||
.compact-agent-table :deep(.el-table__header .cell) {
|
||
line-height: 1.25;
|
||
white-space: nowrap;
|
||
font-size: 12px;
|
||
padding: 0;
|
||
}
|
||
.compact-agent-table :deep(.el-table__body .cell) {
|
||
padding: 0;
|
||
line-height: 1.35;
|
||
font-size: 13px;
|
||
}
|
||
.compact-agent-table :deep(.el-tag) {
|
||
max-width: 100%;
|
||
}
|
||
.nested-table {
|
||
margin-bottom: 4px;
|
||
}
|
||
.inner-tabs {
|
||
margin-top: 0;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__header) {
|
||
margin-bottom: 4px;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__nav-wrap) {
|
||
margin-bottom: 0;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__item) {
|
||
height: 34px;
|
||
line-height: 34px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #888;
|
||
padding: 0 14px;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__item.is-active) {
|
||
color: #d4fde5;
|
||
font-weight: 700;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__active-bar) {
|
||
background-color: var(--gold-bright);
|
||
height: 2px;
|
||
}
|
||
.inner-tabs :deep(.el-tabs__content) {
|
||
padding: 0;
|
||
}
|
||
.inner-table {
|
||
border-radius: 6px;
|
||
}
|
||
.inner-table :deep(.el-table__cell) {
|
||
font-size: 13px;
|
||
}
|
||
.account-type-tag {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.player-list-panel .table-wrap :deep(.el-table),
|
||
.agent-list-panel .table-wrap :deep(.el-table) {
|
||
min-width: 880px;
|
||
}
|
||
|
||
.expand-panel .inner-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.expand-panel :deep(.el-table__body-wrapper) {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
|
||
padding-right: 0;
|
||
}
|
||
|
||
.invite-prominent-btn {
|
||
position: static;
|
||
align-self: flex-end;
|
||
margin: 0 0 8px;
|
||
}
|
||
}
|
||
|
||
/* ─── Inherited from old pages ─── */
|
||
.settings-form :deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
margin-right: 12px;
|
||
}
|
||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||
:deep(.create-account-dialog .el-dialog__body) {
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
padding: 14px 20px 6px;
|
||
}
|
||
:deep(.create-account-dialog .el-dialog__footer) {
|
||
padding-top: 4px;
|
||
}
|
||
.compact-create-form :deep(.el-form-item) {
|
||
margin-bottom: 10px;
|
||
}
|
||
.compact-create-form :deep(.el-form-item__label) {
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
.credit-quick-row {
|
||
margin-top: 2px;
|
||
}
|
||
.credit-quick-btns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 6px;
|
||
}
|
||
.create-meta-bar {
|
||
margin-bottom: 12px;
|
||
padding: 10px 12px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
.create-meta-bar--inline {
|
||
margin-top: -4px;
|
||
}
|
||
.create-meta-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
.create-meta-row + .create-meta-row {
|
||
margin-top: 6px;
|
||
}
|
||
.create-meta-label {
|
||
flex-shrink: 0;
|
||
color: #888;
|
||
}
|
||
.create-meta-value {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #ddd;
|
||
font-weight: 600;
|
||
}
|
||
.create-meta-row .c-green {
|
||
font-size: 14px;
|
||
}
|
||
.create-form-pair {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0 10px;
|
||
}
|
||
.c-green { color: var(--gold-text); }
|
||
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||
.invite-code-cell {
|
||
font-family: ui-monospace, monospace;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.affiliation-tag {
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||
.text-muted { color: #666; font-size: 12px; }
|
||
.list-settings-block--danger {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed rgba(245, 108, 108, 0.35);
|
||
}
|
||
.list-settings-hint {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin: 0 0 10px;
|
||
line-height: 1.5;
|
||
}
|
||
.list-settings-unit {
|
||
margin-left: 4px;
|
||
font-size: 12px;
|
||
color: #888;
|
||
}
|
||
.reset-db-alert { margin-bottom: 10px; }
|
||
.detail-block { margin-bottom: 16px; }
|
||
.section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.section-title--row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
</style>
|