feat: multi-tier agent hierarchy, wallet ledger, and player UX polish

Add configurable agent max level and default sub-agent credit ratio, per-agent block direct player login on suspend, admin/agent wallet transaction views, and match detail my-bets section with refreshed player card styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 16:15:34 +08:00
parent 641c92a5f5
commit ef6b15f119
39 changed files with 2398 additions and 410 deletions

View File

@@ -659,8 +659,7 @@ body {
border-color: #2a2a2a !important;
}
.user-edit-dialog .el-dialog__body,
.agent-edit-dialog .el-dialog__body,
.create-account-dialog .el-dialog__body {
.agent-edit-dialog .el-dialog__body {
max-height: min(70vh, 640px);
overflow-y: auto;
}

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletTxTypeKey } from '../utils/walletTx';
interface WalletTxRow {
id: string;
transactionId: string;
transactionType: string;
amount: string;
balanceBefore: string;
balanceAfter: string;
frozenBefore: string;
frozenAfter: string;
betNo: string | null;
operatorUsername: string | null;
remark: string | null;
createdAt: string;
}
const props = defineProps<{
modelValue: boolean;
playerId: string;
playerUsername?: string | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const { t, locale, localeTag } = useAdminLocale();
const auth = useAuthStore();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const dialogTitle = computed(() => {
const name = props.playerUsername?.trim() || props.playerId;
return t('user.wallet_ledger_dialog_title', { name });
});
const walletApiPath = computed(() =>
auth.isAdmin.value ? '/admin/wallet/transactions' : '/agent/wallet/ledger-transactions',
);
const items = ref<WalletTxRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const typeCategory = ref('');
const dateRange = ref<[Date, Date] | null>(null);
function walletTypeLabel(type: string) {
const key = walletTxTypeKey(type);
return key ? t(key) : type;
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function dateParams() {
if (!dateRange.value?.length) return {};
const [from, to] = dateRange.value;
const end = new Date(to);
end.setHours(23, 59, 59, 999);
return {
dateFrom: from.toISOString(),
dateTo: end.toISOString(),
};
}
async function load() {
if (!props.playerId) return;
loading.value = true;
try {
const { data } = await api.get(walletApiPath.value, {
params: {
page: page.value,
pageSize: pageSize.value,
playerId: props.playerId,
typeCategory: typeCategory.value || undefined,
...dateParams(),
},
});
items.value = (data.data?.items ?? []) as WalletTxRow[];
total.value = data.data?.total ?? 0;
} catch {
items.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
void load();
}
function resetFilters() {
typeCategory.value = '';
dateRange.value = null;
page.value = 1;
}
watch(
() => [props.modelValue, props.playerId] as const,
([open, id]) => {
if (open && id) {
resetFilters();
void load();
}
},
);
</script>
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="960px"
destroy-on-close
class="player-wallet-ledger-dialog"
append-to-body
>
<el-form inline class="ledger-filter">
<el-form-item :label="t('finance.filter.date_range')">
<el-date-picker
v-model="dateRange"
type="daterange"
:start-placeholder="t('common.to')"
:end-placeholder="t('common.to')"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item :label="t('finance.filter.type_category')">
<el-select v-model="typeCategory" clearable :placeholder="t('finance.filter.type_category_all')" style="width: 120px">
<el-option :label="t('finance.filter.type_category_deposit')" value="deposit" />
<el-option :label="t('finance.filter.type_category_bet')" value="bet" />
<el-option :label="t('finance.filter.type_category_cashback')" value="cashback" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :key="`${locale}-${playerId}`" :data="items" stripe max-height="420">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" width="92">
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_change')" width="100" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_before')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
<span>{{ formatAmount(row.balanceBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_after')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
<span>{{ formatAmount(row.balanceAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_before')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenBefore)" placement="top">
<span>{{ formatAmount(row.frozenBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_after')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenAfter)" placement="top">
<span>{{ formatAmount(row.frozenAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.reference')" min-width="110" show-overflow-tooltip>
<template #default="{ row }">
<router-link
v-if="row.betNo"
:to="{ path: '/bets', query: { keyword: row.betNo } }"
class="bet-link"
@click="visible = false"
>
{{ row.betNo }}
</router-link>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="88">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark ?? '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="() => load()"
@size-change="() => { page = 1; load(); }"
/>
</div>
</el-dialog>
</template>
<style scoped>
.ledger-filter {
margin-bottom: 12px;
}
.ledger-filter :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.amt-pos {
color: var(--el-color-success);
font-weight: 600;
}
.amt-neg {
color: var(--el-color-danger);
font-weight: 600;
}
.bet-link {
color: var(--el-color-primary);
text-decoration: none;
}
.bet-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -47,7 +47,7 @@ const zh: Record<string, string> = {
'nav.smoke_tests': '自动化测试',
'nav.media': '媒体库',
'nav.players': '直属玩家',
'nav.subAgents': '级代理',
'nav.subAgents': '级代理',
'nav.myBets': '注单查询',
'nav.open_menu': '打开菜单',
'nav.close_menu': '关闭菜单',
@@ -174,7 +174,7 @@ const zh: Record<string, string> = {
'agent_dash.liability_child': '下级代理占用',
'page.agent_players.title': '直属玩家',
'page.agent_players.desc': '管理你名下的直属玩家',
'page.agent_sub.title': '级代理',
'page.agent_sub.title': '级代理',
'page.agent_sub.desc': '管理二级代理账号与授信分配',
'page.agent_bets.title': '注单查询',
'page.agent_bets.desc': '下级玩家的全部投注记录',
@@ -247,7 +247,7 @@ const en: Record<string, string> = {
'nav.smoke_tests': 'Smoke tests',
'nav.media': 'Media Library',
'nav.players': 'My Players',
'nav.subAgents': 'Sub-Agents',
'nav.subAgents': 'Tier-2 agents',
'nav.myBets': 'Bet Search',
'nav.open_menu': 'Open menu',
'nav.close_menu': 'Close menu',
@@ -374,7 +374,7 @@ const en: Record<string, string> = {
'agent_dash.liability_child': 'Sub-agent exposure',
'page.agent_players.title': 'My players',
'page.agent_players.desc': 'Players under your account',
'page.agent_sub.title': 'Sub-agents',
'page.agent_sub.title': 'Tier-2 agents',
'page.agent_sub.desc': 'Manage tier-2 agents and credit allocation',
'page.agent_bets.title': 'Bet search',
'page.agent_bets.desc': 'All bets from downstream players',
@@ -447,7 +447,7 @@ const ms: Record<string, string> = {
'nav.smoke_tests': 'Ujian asap',
'nav.media': 'Perpustakaan Media',
'nav.players': 'Pemain saya',
'nav.subAgents': 'Sub-ejen',
'nav.subAgents': 'Ejen peringkat 2',
'nav.myBets': 'Carian pertaruhan',
'nav.open_menu': 'Buka menu',
'nav.close_menu': 'Tutup menu',
@@ -574,7 +574,7 @@ const ms: Record<string, string> = {
'agent_dash.liability_child': 'Pendedahan ejen bawahan',
'page.agent_players.title': 'Pemain saya',
'page.agent_players.desc': 'Pemain di bawah akaun anda',
'page.agent_sub.title': 'Sub-ejen',
'page.agent_sub.title': 'Ejen peringkat 2',
'page.agent_sub.desc': 'Urus ejen peringkat 2 dan peruntukan kredit',
'page.agent_bets.title': 'Carian pertaruhan',
'page.agent_bets.desc': 'Semua pertaruhan pemain hiliran',

View File

@@ -100,13 +100,21 @@ export const adminPagesMs: Record<string, string> = {
'user.field.account_type': 'Jenis akaun',
'user.type.player': 'Pemain',
'user.type.tier1_agent': 'Ejen peringkat 1',
'user.type.sub_agent': 'Sub-ejen',
'user.type.sub_agent': 'Ejen peringkat 2',
'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
'agent.create_btn': '+ Ejen peringkat 1 baharu',
'agent.create_sub_btn': '+ Ejen peringkat 2 baharu',
'agent.create_sub': 'Cipta sub-ejen',
'agent.hint.sub_agent_parent': 'Ejen peringkat 2 mesti di bawah ejen peringkat 1',
'agent.create_sub': 'Cipta ejen peringkat 2',
'agent.create_child_btn': '+ Sub-ejen baharu',
'agent.dialog.create_child_agent': 'Sub-ejen baharu',
'agent.create_level_agent': 'Cipta ejen peringkat {level}',
'agent.create_level_agent_btn': '+ Ejen peringkat {level} baharu',
'agent.level_name': 'Ejen peringkat {level}',
'agent.level_tab': 'Ejen peringkat {level}',
'agent.dialog.create_level_agent': 'Ejen peringkat {level} baharu',
'agent.hint.select_parent_for_level': 'Pilih ejen peringkat {level} sebagai induk',
'agent.err.parent_level_mismatch': 'Peringkat induk tidak sah untuk cipta ejen peringkat {level}',
'agent.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini',
'agent.filter.username_ph': 'Nama pengguna',
'agent_mgr.tab.players': 'Pemain',
@@ -150,6 +158,37 @@ export const adminPagesMs: Record<string, string> = {
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
'finance.tab.credit': 'Lejar kredit',
'finance.tab.transfer': 'Lejar pemindahan',
'finance.tab.wallet': 'Lejar dompet',
'finance.filter.type_category': 'Jenis transaksi',
'finance.filter.type_category_all': 'Semua',
'finance.filter.type_category_deposit': 'Pemindahan',
'finance.filter.type_category_bet': 'Pertaruhan',
'finance.filter.type_category_cashback': 'Rebat',
'finance.col.frozen_before': 'Beku sebelum',
'finance.col.frozen_after': 'Beku selepas',
'finance.col.reference': 'Pertaruhan berkaitan',
'finance.tx.adjust': 'Pelarasan baki',
'finance.tx.bet_freeze': 'Beku pertaruhan',
'finance.tx.bet_deduct': 'Potong pertaruhan',
'finance.tx.bet_win': 'Bayaran pertaruhan',
'finance.tx.bet_lose': 'Penyelesaian pertaruhan',
'finance.tx.bet_push': 'Refund seri',
'finance.tx.bet_refund': 'Refund pertaruhan',
'finance.tx.bet_void': 'Pertaruhan batal',
'finance.tx.cashback': 'Rebat',
'finance.tx.resettle': 'Penyelesaian semula',
'user.action.view_wallet_ledger': 'Lihat lejar dompet',
'user.wallet_ledger_dialog_title': 'Lejar dompet — {name}',
'agent.hierarchy.settings_title': 'Hierarki ejen',
'agent.hierarchy.settings_hint': '0 bermaksud tanpa had. Ejen di had atas tidak boleh cipta sub-ejen.',
'agent.hierarchy.max_level': 'Tahap ejen maksimum',
'agent.hierarchy.default_sub_credit_ratio': 'Nisbah kredit sub-ejen lalai',
'agent.hierarchy.create_credit_default_hint': 'Lalai {ratio}% ({amount}), tidak melebihi kredit induk; boleh diselaraskan',
'agent.hierarchy.create_credit_quick_hint': 'Kredit induk tersedia {amount} — klik nisbah untuk isi',
'agent.hierarchy.create_level_hint': 'Akan dicipta sebagai ejen peringkat {n}',
'agent.field.parent_agent': 'Ejen induk',
'agent.col.parent_chain': 'Rantaian induk',
'role.agent_level': 'Ejen peringkat {n}',
'finance.filter.date_range': 'Julat tarikh',
'finance.filter.player_ph': 'Nama pengguna pemain',
'finance.filter.parent_agent_ph': 'Nama/ID ejen induk',

View File

@@ -106,13 +106,21 @@ export const adminPagesZh: Record<string, string> = {
'agent.create_btn': '+ 新建一级代理',
'agent.create_sub_btn': '+ 新建二级代理',
'agent.create_sub': '创建二级代理',
'agent.hint.sub_agent_parent': '二级代理必须挂靠在一级代理名下',
'agent.create_child_btn': '+ 新建下级代理',
'agent.dialog.create_child_agent': '新建下级代理',
'agent.create_level_agent': '创建{level}级代理',
'agent.create_level_agent_btn': '+ 新建{level}级代理',
'agent.level_name': '{level}级代理',
'agent.level_tab': '{level}级代理',
'agent.dialog.create_level_agent': '新建{level}级代理',
'agent.hint.select_parent_for_level': '请选择 {level} 级代理作为上级',
'agent.err.parent_level_mismatch': '上级代理层级不正确,无法创建 {level} 级代理',
'agent.hint.creating_under_agent': '在此代理下创建账号',
'agent.filter.username_ph': '用户名',
'agent_mgr.tab.players': '玩家',
'agent_mgr.tab.agents': '代理',
'agent.col.level': '层级',
'agent.col.credit': '授信 / 已用 / 可用',
'agent.col.credit': '授信/已用/可用',
'agent.col.direct_players': '直属玩家',
'agent.direct_players_title': '直属玩家 · {name}',
'agent.platform_row_name': '平台',
@@ -153,6 +161,38 @@ export const adminPagesZh: Record<string, string> = {
'agent.credit_tx.view_all': '查看全部额度流水',
'finance.tab.credit': '额度流水',
'finance.tab.transfer': '上下分流水',
'finance.tab.wallet': '钱包流水',
'finance.filter.type_category': '流水类型',
'finance.filter.type_category_all': '全部',
'finance.filter.type_category_deposit': '上下分',
'finance.filter.type_category_bet': '投注',
'finance.filter.type_category_cashback': '返水',
'finance.col.frozen_before': '变动前冻结',
'finance.col.frozen_after': '变动后冻结',
'finance.col.reference': '关联注单',
'finance.tx.adjust': '余额调整',
'finance.tx.bet_freeze': '投注冻结',
'finance.tx.bet_deduct': '投注扣款',
'finance.tx.bet_win': '投注派彩',
'finance.tx.bet_lose': '投注结算',
'finance.tx.bet_push': '走水返还',
'finance.tx.bet_refund': '投注退款',
'finance.tx.bet_void': '注单作废',
'finance.tx.cashback': '返水',
'finance.tx.resettle': '重结算调整',
'user.action.view_wallet_ledger': '查看资金流水',
'user.wallet_ledger_dialog_title': '{name} 的资金流水',
'agent.hierarchy.settings_title': '代理层级设置',
'agent.hierarchy.settings_hint': '0 表示不限制代理层级;达到上限的代理将无法创建下级。下级默认授信比例用于创建下级代理时的预填额度。',
'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整',
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
'agent.field.parent_agent': '上级代理',
'agent.col.parent_chain': '上级链路',
'role.agent_level': '{n}级代理',
'finance.filter.date_range': '时间范围',
'finance.filter.player_ph': '玩家用户名',
'finance.filter.parent_agent_ph': '上级代理用户名或 ID',
@@ -176,17 +216,15 @@ export const adminPagesZh: Record<string, string> = {
'agent.field.select_user': '选择用户',
'agent.ph.select_user': '搜索玩家用户名',
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
'agent.suspend.settings_title': '代理停用策略',
'agent.suspend.settings_hint': 'MVP 默认仅停用代理操作权限,不自动冻结或禁止其直属玩家登录。',
'agent.suspend.freeze_direct_players': '停用时允许级联冻结直属玩家',
'agent.suspend.block_player_login': '上级代理停用时禁止直属玩家登录',
'agent.suspend.cascade_disabled_hint': '未开启级联冻结,仅停用该代理操作权限,直属玩家不受影响。',
'agent.freeze.confirm_freeze_title': '确认停用代理',
'agent.freeze.confirm_freeze_body': '确定停用代理「{name}」?停用后该代理无法登录代理端。',
'agent.freeze.confirm_unfreeze_body': '确定恢复代理「{name}」为正常状态?',
'agent.freeze.cascade_hint': '是否同时冻结该代理名下所有直属玩家账号?',
'agent.freeze.cascade_label': '级联冻结直属玩家',
'agent.freeze.opt_freeze_direct_players': '同时冻结直属玩家',
'agent.freeze.opt_block_player_login': '禁止直属玩家登录',
'agent.unfreeze.confirm_title': '确认恢复代理',
'agent.unfreeze.opt_unfreeze_direct_players': '同时解冻直属玩家',
'agent.msg.cascade_freeze_done': '已停用代理并冻结其直属玩家',
'agent.msg.cascade_unfreeze_done': '已恢复代理并解冻其直属玩家',
'agent.msg.freeze_done': '已{action}',
'match.create_btn': '+ 新增联赛',
@@ -931,13 +969,21 @@ export const adminPagesEn: Record<string, string> = {
'user.field.account_type': 'Account type',
'user.type.player': 'Player',
'user.type.tier1_agent': 'Tier-1 agent',
'user.type.sub_agent': 'Sub-agent',
'user.type.sub_agent': 'Tier-2 agent',
'user.hint.account_type': 'Agents use credit limits; players can belong to an agent and receive top-ups',
'agent.create_btn': '+ New tier-1 agent',
'agent.create_sub_btn': '+ New tier-2 agent',
'agent.create_sub': 'Create sub-agent',
'agent.hint.sub_agent_parent': 'Tier-2 agents must belong to a tier-1 agent',
'agent.create_sub': 'Create tier-2 agent',
'agent.create_child_btn': '+ New sub-agent',
'agent.dialog.create_child_agent': 'New sub-agent',
'agent.create_level_agent': 'Create L{level} agent',
'agent.create_level_agent_btn': '+ New L{level} agent',
'agent.level_name': 'Tier-{level} agent',
'agent.level_tab': 'L{level} agents',
'agent.dialog.create_level_agent': 'New L{level} agent',
'agent.hint.select_parent_for_level': 'Select a level-{level} agent as parent',
'agent.err.parent_level_mismatch': 'Invalid parent level for creating a level-{level} agent',
'agent.hint.creating_under_agent': 'Create account under this agent',
'agent.filter.username_ph': 'Username',
'agent_mgr.tab.players': 'Players',
@@ -984,6 +1030,38 @@ export const adminPagesEn: Record<string, string> = {
'agent.credit_tx.view_all': 'View all credit ledger',
'finance.tab.credit': 'Credit ledger',
'finance.tab.transfer': 'Transfer ledger',
'finance.tab.wallet': 'Wallet ledger',
'finance.filter.type_category': 'Transaction type',
'finance.filter.type_category_all': 'All',
'finance.filter.type_category_deposit': 'Transfers',
'finance.filter.type_category_bet': 'Bets',
'finance.filter.type_category_cashback': 'Cashback',
'finance.col.frozen_before': 'Frozen before',
'finance.col.frozen_after': 'Frozen after',
'finance.col.reference': 'Related bet',
'finance.tx.adjust': 'Balance adjustment',
'finance.tx.bet_freeze': 'Bet freeze',
'finance.tx.bet_deduct': 'Bet deduct',
'finance.tx.bet_win': 'Bet payout',
'finance.tx.bet_lose': 'Bet settlement',
'finance.tx.bet_push': 'Push refund',
'finance.tx.bet_refund': 'Bet refund',
'finance.tx.bet_void': 'Bet void',
'finance.tx.cashback': 'Cashback',
'finance.tx.resettle': 'Resettlement',
'user.action.view_wallet_ledger': 'View wallet ledger',
'user.wallet_ledger_dialog_title': 'Wallet ledger — {name}',
'agent.hierarchy.settings_title': 'Agent hierarchy',
'agent.hierarchy.settings_hint': '0 means unlimited levels. Agents at the cap cannot create sub-agents. The default credit ratio pre-fills sub-agent credit limits.',
'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
'agent.field.parent_agent': 'Parent agent',
'agent.col.parent_chain': 'Parent chain',
'role.agent_level': 'Level {n} agent',
'finance.filter.date_range': 'Date range',
'finance.filter.player_ph': 'Player username',
'finance.filter.parent_agent_ph': 'Parent agent username or ID',
@@ -1007,17 +1085,15 @@ export const adminPagesEn: Record<string, string> = {
'agent.field.select_user': 'Select user',
'agent.ph.select_user': 'Search player username',
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
'agent.suspend.settings_title': 'Agent suspension policy',
'agent.suspend.settings_hint': 'MVP default: suspend agent operations only; do not auto-freeze or block direct players.',
'agent.suspend.freeze_direct_players': 'Allow cascade freeze of direct players on suspend',
'agent.suspend.block_player_login': 'Block direct player login when parent agent is suspended',
'agent.suspend.cascade_disabled_hint': 'Cascade freeze is off; only the agent is suspended, direct players are unaffected.',
'agent.freeze.confirm_freeze_title': 'Confirm suspend agent',
'agent.freeze.confirm_freeze_body': 'Suspend agent "{name}"? They will not be able to sign in to the agent portal.',
'agent.freeze.confirm_unfreeze_body': 'Restore agent "{name}" to active status?',
'agent.freeze.cascade_hint': 'Also freeze all direct player accounts under this agent?',
'agent.freeze.cascade_label': 'Cascade freeze direct players',
'agent.freeze.opt_freeze_direct_players': 'Also freeze direct players',
'agent.freeze.opt_block_player_login': 'Block direct player login',
'agent.unfreeze.confirm_title': 'Confirm restore agent',
'agent.unfreeze.opt_unfreeze_direct_players': 'Also unfreeze direct players',
'agent.msg.cascade_freeze_done': 'Agent suspended and direct players frozen',
'agent.msg.cascade_unfreeze_done': 'Agent restored and direct players unfrozen',
'agent.msg.freeze_done': '{action} completed',
'match.create_btn': '+ New league',

View File

@@ -64,8 +64,10 @@ const topbarCrumbs = computed(() => resolveAdminBreadcrumb(route.path, t));
const roleLabel = computed(() => {
if (auth.isAdmin.value) return t('role.admin');
if (auth.isTier1Agent.value) return t('role.tier1_agent');
if (auth.isTier2Agent.value) return t('role.tier2_agent');
const level = auth.user.value?.agentLevel;
if (auth.isAgent.value && level != null && level > 0) {
return t('role.agent_level', { n: level });
}
return t('role.agent');
});

View File

@@ -9,6 +9,8 @@ export interface StaffUser {
locale?: string;
role?: string;
agentLevel?: number | null;
maxAgentLevel?: number | null;
canManageSubAgents?: boolean;
}
const TOKEN_KEY = 'manage_token';
@@ -127,6 +129,15 @@ export function useAuthStore() {
const isAgent = computed(() => resolveUserType() === 'AGENT');
const isTier1Agent = computed(() => isAgent.value && user.value?.agentLevel === 1);
const isTier2Agent = computed(() => isAgent.value && user.value?.agentLevel === 2);
const canManageSubAgents = computed(() => {
if (!isAgent.value) return false;
if (user.value?.canManageSubAgents != null) return user.value.canManageSubAgents;
const level = user.value?.agentLevel;
const max = user.value?.maxAgentLevel;
if (level == null || level < 1) return false;
if (max == null || max === 0) return true;
return level < max;
});
const portalLabel = computed(() => (isAdmin.value ? '平台后台' : '代理后台'));
function setSession(newToken: string, newUser: StaffUser) {
@@ -149,6 +160,7 @@ export function useAuthStore() {
isAgent,
isTier1Agent,
isTier2Agent,
canManageSubAgents,
portalLabel,
setSession,
logout,

View File

@@ -0,0 +1,20 @@
const ZH_LEVEL_NUMERALS: Record<number, string> = {
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
7: '七',
8: '八',
9: '九',
10: '十',
};
/** 中文界面用「一、二、三…」,其他语言用阿拉伯数字 */
export function formatAgentLevelNumeral(level: number, locale: string): string {
if (locale.startsWith('zh') && ZH_LEVEL_NUMERALS[level]) {
return ZH_LEVEL_NUMERALS[level];
}
return String(level);
}

View File

@@ -47,6 +47,8 @@ export async function hydrateStaffSession(): Promise<boolean> {
locale: raw.locale,
role: raw.role,
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
maxAgentLevel: typeof raw.maxAgentLevel === 'number' ? raw.maxAgentLevel : null,
canManageSubAgents: raw.canManageSubAgents === true,
});
return true;
} catch (e: unknown) {

View File

@@ -0,0 +1,23 @@
export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'finance.tx.deposit',
MANUAL_WITHDRAW: 'finance.tx.withdraw',
MANUAL_ADJUST: 'finance.tx.adjust',
BET_FREEZE: 'finance.tx.bet_freeze',
BET_DEDUCT: 'finance.tx.bet_deduct',
BET_SETTLE_WIN: 'finance.tx.bet_win',
BET_SETTLE_LOSE: 'finance.tx.bet_lose',
BET_SETTLE_PUSH: 'finance.tx.bet_push',
BET_WIN: 'finance.tx.bet_win',
BET_REFUND: 'finance.tx.bet_refund',
BET_VOID: 'finance.tx.bet_void',
BET_VOID_REFUND: 'finance.tx.bet_void',
CASHBACK: 'finance.tx.cashback',
CASHBACK_DEPOSIT: 'finance.tx.cashback',
RESETTLE_REVERSE: 'finance.tx.resettle',
DEPOSIT: 'finance.tx.deposit',
WITHDRAW: 'finance.tx.withdraw',
};
export function walletTxTypeKey(type: string): string {
return TX_KEY_MAP[type.toUpperCase()] ?? '';
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -37,11 +37,13 @@ import {
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 AgentCreditContext from '../components/AgentCreditContext.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
@@ -59,15 +61,69 @@ const tier1PageSize = ref(20);
const tier1Keyword = ref('');
const tier1FilterStatus = ref('');
/* ─── Tier-2 agent list ─── */
const tier2Agents = ref<AgentRow[]>([]);
const tier2Total = ref(0);
const tier2Page = ref(1);
const tier2PageSize = ref(20);
const tier2Keyword = ref('');
const tier2FilterStatus = ref('');
/* ─── Sub-agent lists by level (L2, L3, …) ─── */
type SubAgentLevelState = {
agents: AgentRow[];
total: number;
page: number;
pageSize: number;
keyword: string;
filterStatus: string;
};
/* ─── View tab: players | tier1Agents | tier2Agents ─── */
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
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 ─── */
@@ -86,7 +142,8 @@ const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const tier1AgentTableRef = ref();
const tier2AgentTableRef = ref();
const createToolbarChildLevel = ref<number | null>(null);
/* ─── Dialogs ─── */
const createVisible = ref(false);
@@ -132,11 +189,16 @@ const bettingLimits = ref({
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const agentSuspendSettings = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 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 agentSuspendSaving = ref(false);
const hierarchySaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
@@ -144,27 +206,161 @@ const settingsCollapseOpen = ref<string[]>([]);
const createDialogTitle = computed(() => {
if (createAccountMode.value === 1) return t('agent.dialog.create');
if (createAccountMode.value === 2) return t('agent_portal.create_sub_agent_dialog');
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, hierarchySettings.value.defaultSubAgentCreditRatio ?? 50);
}
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 }) {
if (a.level === 2 && a.parentUsername) {
return `${a.parentUsername} / ${a.username} (#${a.id})`;
}
return `${a.username} (#${a.id})`;
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
return `L${a.level} ${chain} (#${a.id})`;
}
function resolveCreateParentLabel(agentId: string) {
const hit = agentOptions.value.find((a) => a.id === agentId);
if (hit) return agentOptionLabel(hit);
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 tier2 = tier2Agents.value.find((a) => a.userId === agentId);
if (tier2) {
return tier2.parentUsername ? `${tier2.parentUsername} / ${tier2.username}` : tier2.username;
const row = findAgentRowByUserId(agentId);
if (row) {
return row.parentUsername ? `${row.parentUsername} / ${row.username}` : row.username;
}
return agentId;
}
@@ -173,12 +369,16 @@ function resolveCreateParentLabel(agentId: string) {
onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadAgentSuspendSettings();
loadHierarchySettings();
loadResetDatabaseStatus();
loadAgentOptions();
loadAllPlayers();
loadTier1Agents();
loadTier2Agents();
loadAgentLevelCounts().then(() => {
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
});
});
/* ─── Load tier-1 agents ─── */
@@ -212,40 +412,63 @@ function searchTier1Agents() {
loadTier1Agents();
}
/* ─── Load tier-2 agents ─── */
async function loadTier2Agents() {
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);
const { data } = await api.get('/admin/agents', {
params: {
page: tier2Page.value,
pageSize: tier2PageSize.value,
keyword: tier2Keyword.value.trim() || undefined,
status: tier2FilterStatus.value || undefined,
level: 2,
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
tier2Agents.value = data.data.items as AgentRow[];
tier2Total.value = data.data.total;
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
}
function onTier2PageChange(p: number) {
tier2Page.value = p;
loadTier2Agents();
function onSubAgentPageChange(level: number, p: number) {
ensureSubAgentState(level).page = p;
loadSubAgentsAtLevel(level);
}
function onTier2SizeChange(size: number) {
tier2PageSize.value = size;
tier2Page.value = 1;
loadTier2Agents();
function onSubAgentSizeChange(level: number, size: number) {
const st = ensureSubAgentState(level);
st.pageSize = size;
st.page = 1;
loadSubAgentsAtLevel(level);
}
function searchTier2Agents() {
tier2Page.value = 1;
loadTier2Agents();
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();
loadTier2Agents();
void reloadSubAgentTabs();
}
/* ─── Load main agent list ─── */
@@ -313,10 +536,24 @@ function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent
onAgentRowClick(row, tier1AgentTableRef, _column, event);
}
function onTier2AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, tier2AgentTableRef, _column, event);
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
const tableRef = { value: subAgentTableRefs.value[level] };
onAgentRowClick(row, tableRef, _column, 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));
@@ -442,30 +679,40 @@ async function savePlayerSettings() {
}
}
async function loadAgentSuspendSettings() {
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendSettings.value = data.data;
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = data.data;
} catch {
/* defaults */
hierarchySettings.value = { maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 };
}
}
async function saveAgentSuspendSettings() {
agentSuspendSaving.value = true;
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
agentSuspendSettings.value = data.data;
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadAgentSuspendSettings();
loadHierarchySettings();
} finally {
agentSuspendSaving.value = false;
hierarchySaving.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();
@@ -500,15 +747,19 @@ function openCreateSubAgent(parentAgentUserId: string) {
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() {
function openCreateSubAgentFromToolbar(childLevel: number) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = '';
createParentLocked.value = false;
createToolbarChildLevel.value = childLevel;
const parents = parentOptionsForChildLevel(childLevel);
createParentAgentId.value = parents.length === 1 ? parents[0].id : '';
createAccountMode.value = 2;
createVisible.value = true;
}
@@ -519,10 +770,20 @@ async function submitCreate() {
let payload: Record<string, unknown>;
try {
if (isSubAgent) {
if (!createParentAgentId.value) throw new Error(t('agent.hint.sub_agent_parent'));
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,
@@ -555,8 +816,12 @@ async function submitCreate() {
: 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);
@@ -752,87 +1017,58 @@ async function toggleFreezePlayer(row: PlayerRow) {
}
/* ─── Freeze / Unfreeze Agent ─── */
async function toggleFreezeAgent(row: AgentRow) {
const accountStatus = subAgentAccountStatus(row);
const freeze = accountStatus === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
const freezeAgentIsSuspend = computed(() => {
if (!freezeAgentTarget.value) return true;
return subAgentAccountStatus(freezeAgentTarget.value) === 'ACTIVE';
});
if (!freeze) {
// Unfreeze: simple confirm
try {
await ElMessageBox.confirm(
t('agent.freeze.confirm_unfreeze_body', { name: row.username }),
t('agent.freeze.confirm_freeze_title'),
{ type: 'info', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch {
return;
}
try {
await api.put(`/admin/agents/${row.userId}`, { status: 'ACTIVE' });
ElMessage.success(t('agent.msg.freeze_done', { action }));
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
return;
}
function toggleFreezeAgent(row: AgentRow) {
freezeAgentTarget.value = row;
freezeAgentForm.value = {
freezeDirectPlayers: false,
blockDirectPlayerLogin: false,
unfreezeDirectPlayers: false,
};
freezeAgentVisible.value = true;
}
// Freeze: offer cascade option via a custom dialog
let freezeDirectPlayers = false;
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 {
await ElMessageBox.confirm(
t('agent.freeze.confirm_freeze_body', { name: row.username }),
t('agent.freeze.confirm_freeze_title'),
{
type: 'warning',
confirmButtonText: action,
cancelButtonText: t('common.cancel'),
distinguishCancelAndClose: true,
},
);
} catch {
return;
}
// After confirming freeze, ask about cascade (only when enabled in settings)
if (row.directPlayerCount > 0 && agentSuspendSettings.value.suspendFreezeDirectPlayers) {
try {
await ElMessageBox.confirm(
t('agent.freeze.cascade_hint'),
t('agent.freeze.cascade_label'),
{
type: 'warning',
confirmButtonText: t('common.yes') || '是',
cancelButtonText: t('common.no') || '否',
distinguishCancelAndClose: true,
},
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 }),
);
freezeDirectPlayers = true;
} catch {
freezeDirectPlayers = false;
}
} else if (row.directPlayerCount > 0 && !agentSuspendSettings.value.suspendFreezeDirectPlayers) {
ElMessage.info(t('agent.suspend.cascade_disabled_hint'));
}
try {
await api.put(`/admin/agents/${row.userId}`, {
status: 'SUSPENDED',
freezeDirectPlayers,
});
ElMessage.success(
freezeDirectPlayers
? t('agent.msg.cascade_freeze_done')
: t('agent.msg.freeze_done', { action }),
);
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;
}
}
@@ -930,22 +1166,31 @@ function creditTypeLabel(type: string) {
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
<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.suspend.freeze_direct_players')">
<el-switch
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
:loading="agentSuspendSaving"
@change="saveAgentSuspendSettings"
<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 :label="t('agent.suspend.block_player_login')">
<el-switch
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
:loading="agentSuspendSaving"
@change="saveAgentSuspendSettings"
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="5"
controls-position="right"
:disabled="hierarchySaving"
/>
<span class="list-settings-unit">%</span>
</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>
@@ -1080,10 +1325,11 @@ function creditTypeLabel(type: string) {
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="420" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
@@ -1110,7 +1356,7 @@ function creditTypeLabel(type: string) {
</el-tab-pane>
<!-- Tab: 一级代理 -->
<el-tab-pane :label="`${t('user.type.tier1_agent')} (${tier1Total})`" name="tier1Agents">
<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">
@@ -1138,7 +1384,7 @@ function creditTypeLabel(type: string) {
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onTier1AgentRowClick"
>
@@ -1206,25 +1452,25 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="140" />
<el-table-column :label="t('common.status')" width="88">
<el-table-column prop="userId" label="ID" width="64" />
<el-table-column prop="username" :label="t('user.col.username')" width="100" show-overflow-tooltip />
<el-table-column :label="t('common.status')" 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')" width="60" align="center">
<el-table-column prop="level" :label="t('agent.col.level')" width="52" align="center">
<template #default="{ row }">L{{ row.level }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
<el-table-column :label="t('agent.col.credit')" width="132" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="80" align="center" />
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="72" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="72" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
</el-table-column>
@@ -1234,7 +1480,7 @@ function creditTypeLabel(type: string) {
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ t('agent.create_sub') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
@@ -1257,38 +1503,56 @@ function creditTypeLabel(type: string) {
</section>
</el-tab-pane>
<!-- Tab: 级代理 -->
<el-tab-pane :label="`${t('user.type.sub_agent')} (${tier2Total})`" name="tier2Agents">
<!-- 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="tier2Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier2Agents" />
<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="tier2FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<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="searchTier2Agents">{{ t('common.search') }}</el-button>
<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">{{ t('agent.create_sub_btn') }}</el-button>
<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>
<div class="table-wrap">
<el-table
ref="tier2AgentTableRef"
:data="tier2Agents"
:ref="(el) => { subAgentTableRefs[agentLevel] = el as { toggleRowExpansion: (row: AgentRow) => void } | null }"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onTier2AgentRowClick"
@row-click="(row, column, event) => onSubAgentRowClick(agentLevel, row, column, event)"
>
<template #empty>
<AdminTableEmpty />
@@ -1331,30 +1595,31 @@ function creditTypeLabel(type: string) {
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('user.type.tier1_agent')" min-width="120">
<template #default="{ row }">{{ row.parentUsername ?? '—' }}</template>
<el-table-column prop="userId" label="ID" width="64" />
<el-table-column prop="username" :label="t('user.col.username')" width="100" show-overflow-tooltip />
<el-table-column :label="t('agent.col.parent_chain')" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<el-table-column :label="t('common.status')" 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="168" align="right">
<el-table-column :label="t('agent.col.credit')" width="132" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="72" align="center" />
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
@@ -1364,14 +1629,14 @@ function creditTypeLabel(type: string) {
</div>
<div class="pager">
<el-pagination
v-model:current-page="tier2Page"
v-model:page-size="tier2PageSize"
:total="tier2Total"
: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="onTier2PageChange"
@size-change="onTier2SizeChange"
@current-change="(p) => onSubAgentPageChange(agentLevel, p)"
@size-change="(size) => onSubAgentSizeChange(agentLevel, size)"
/>
</div>
</section>
@@ -1380,92 +1645,203 @@ function creditTypeLabel(type: string) {
<!-- ═══════════ 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="520px" destroy-on-close class="create-account-dialog">
<el-form label-width="100px">
<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')"
/>
<div v-if="createAccountMode === 0" class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<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>
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
</el-form-item>
<!-- 玩家从玩家 Tab 进入时可选择一级/二级代理 -->
<template v-if="createAccountMode === 0 && !createParentLocked">
<el-form-item :label="t('user.filter.agent')">
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
</el-select>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
</el-form-item>
</template>
<!-- 二级代理从一级代理行进入时锁定上级 -->
<el-form-item v-if="createAccountMode === 2 && createParentLocked" :label="t('user.type.tier1_agent')">
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
<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>
<!-- 二级代理从二级 Tab 进入时选择一级代理 -->
<template v-if="createAccountMode === 2 && !createParentLocked">
<el-form-item :label="t('user.type.tier1_agent')" required>
<el-select v-model="createParentAgentId" :placeholder="t('user.filter.agent_ph')" style="width: 100%">
<el-option v-for="a in tier1AgentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
</el-select>
<div class="field-hint">{{ t('agent.hint.sub_agent_parent') }}</div>
</el-form-item>
</template>
<!-- 代理字段一级 / 二级 -->
<template v-if="createAccountMode === 1 || createAccountMode === 2">
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
<el-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')">
<el-input-number v-model="createForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
<el-input-number
v-model="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</template>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<!-- Player-only: initial balance -->
<template v-if="createAccountMode === 0">
<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%" />
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</template>
<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>
@@ -1682,6 +2058,11 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
</el-button>
</div>
</template>
</el-dialog>
@@ -1756,6 +2137,12 @@ function creditTypeLabel(type: string) {
</el-table>
</template>
</el-dialog>
<PlayerWalletLedgerDialog
v-model="walletLedgerVisible"
:player-id="walletLedgerPlayerId"
:player-username="walletLedgerPlayerUsername"
/>
</div>
</template>
@@ -1792,6 +2179,18 @@ function creditTypeLabel(type: string) {
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;
@@ -1827,7 +2226,6 @@ function creditTypeLabel(type: string) {
padding: 12px 14px 14px;
background: #141414;
border: 1px solid #2a2a2a;
border-left: 3px solid rgba(47, 181, 106, 0.45);
border-radius: 8px;
}
.expand-loading {
@@ -1850,6 +2248,24 @@ function creditTypeLabel(type: string) {
.expandable-table :deep(.row-expandable) {
cursor: pointer;
}
.compact-agent-table :deep(.el-table__header .el-table__cell),
.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;
}
@@ -1891,9 +2307,6 @@ function creditTypeLabel(type: string) {
font-size: 13px;
font-weight: 600;
}
.expandable-table :deep(.row-expandable) {
cursor: pointer;
}
.action-btns {
display: flex;
align-items: center;
@@ -1909,6 +2322,71 @@ function creditTypeLabel(type: string) {
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: #2fb56a; }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.affiliation-tag {
max-width: 100%;
@@ -1963,6 +2441,11 @@ function creditTypeLabel(type: string) {
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 {

View File

@@ -6,7 +6,6 @@ import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
const { t, locale, localeTag } = useAdminLocale();
const auth = useAuthStore();
const route = useRoute();

View File

@@ -50,6 +50,8 @@ export interface AgentRow {
level: number;
parentAgentId?: string | null;
parentUsername?: string | null;
parentChain?: string[];
parentChainLabel?: string | null;
status: string;
creditLimit: string;
usedCredit: string;

View File

@@ -32,6 +32,7 @@ import {
import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -45,11 +46,16 @@ import {
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
/* L1 agents can manage sub-agents; L2 cannot */
const isTier1 = computed(() => auth.isTier1Agent.value);
const profile = ref<{
creditLimit?: string;
usedCredit?: string;
availableCredit?: string;
canManageSubAgents?: boolean;
}>({});
/* ─── Credit profile ─── */
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
const canManageSubAgents = computed(
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
);
/* ─── Top-level tab: players | subAgents ─── */
const activeTab = ref('players');
@@ -161,7 +167,7 @@ const transferAmountCapError = computed(() => {
onMounted(async () => {
await loadProfile();
await loadPlayers();
if (isTier1.value) {
if (canManageSubAgents.value) {
await loadSubAgents();
}
});
@@ -183,6 +189,16 @@ async function loadPlayers() {
}
}
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;
}
async function loadSubAgents() {
loadingAgents.value = true;
try {
@@ -622,10 +638,11 @@ function statusTagType(s: string) {
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="300" align="center" fixed="right">
<el-table-column :label="t('common.actions')" width="380" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreeze(row)">{{ t('common.freeze') }}</el-button>
@@ -637,7 +654,7 @@ function statusTagType(s: string) {
</el-tab-pane>
<!-- Tab: 下级代理 (仅一级代理可见) -->
<el-tab-pane v-if="isTier1" :label="`${t('nav.subAgents')} (${subAgents.length})`" name="subAgents">
<el-tab-pane v-if="canManageSubAgents" :label="`${t('nav.subAgents')} (${subAgents.length})`" name="subAgents">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
@@ -955,6 +972,12 @@ function statusTagType(s: string) {
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<PlayerWalletLedgerDialog
v-model="walletLedgerVisible"
:player-id="walletLedgerPlayerId"
:player-username="walletLedgerPlayerUsername"
/>
</div>
</template>

View File

@@ -56,8 +56,8 @@ export interface AgentPlayerEditForm {
export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
return {
username: '',
password: 'Player@123',
confirmPassword: 'Player@123',
password: '',
confirmPassword: '',
phone: '',
email: '',
initialDeposit: 0,

View File

@@ -60,8 +60,8 @@ export interface AgentSubAgentCreateForm {
export function emptyAgentSubAgentCreateForm(): AgentSubAgentCreateForm {
return {
username: '',
password: 'Agent@123',
confirmPassword: 'Agent@123',
password: '',
confirmPassword: '',
creditLimit: 10000,
cashbackRate: 0,
maxSingleDeposit: 0,

View File

@@ -91,8 +91,8 @@ export function formatPlayerAffiliationLabel(
export function emptyPlayerCreateForm(): PlayerCreateForm {
return {
username: '',
password: 'Player@123',
confirmPassword: 'Player@123',
password: '',
confirmPassword: '',
parentId: '',
phone: '',
email: '',