Files
thebet365/apps/admin/src/views/AgentManager.vue
Mars e7e938f261 feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档
重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:17:00 +08:00

2763 lines
107 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, 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>