Files
thebet365/apps/admin/src/views/agent/Players.vue
Mars ef6b15f119 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>
2026-06-10 16:15:34 +08:00

1057 lines
44 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, computed, onMounted } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import { resolveFormError } from '../../i18n/form-validation';
import {
emptyAgentPlayerCreateForm,
emptyAgentPlayerEditForm,
buildAgentCreatePlayerPayload,
buildAgentUpdatePlayerPayload,
editFormFromAgentDetail,
type AgentPlayerRow,
type AgentPlayerDetail,
} from './agent-player-form';
import {
emptyAgentSubAgentCreateForm,
buildAgentSubAgentCreatePayload,
emptyAgentSubAgentEditForm,
editFormFromSubAgentDetail,
buildAgentSubAgentUpdatePayload,
subAgentAccountStatus,
type AgentSubAgentRow,
type AgentSubAgentDetail,
} from './agent-sub-agent-form';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../../utils/expandable-table';
import 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,
type WalletTransferContext as WalletTransferContextData,
} from '../../utils/wallet-transfer-context';
import {
snapshotFromAgentRow,
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
const profile = ref<{
creditLimit?: string;
usedCredit?: string;
availableCredit?: string;
canManageSubAgents?: boolean;
}>({});
const canManageSubAgents = computed(
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
);
/* ─── Top-level tab: players | subAgents ─── */
const activeTab = ref('players');
/* ─── Players ─── */
const players = ref<AgentPlayerRow[]>([]);
const loadingPlayers = ref(false);
const keyword = ref('');
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref(emptyAgentPlayerCreateForm());
const editVisible = ref(false);
const editLoading = ref(false);
const editForm = ref(emptyAgentPlayerEditForm());
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<AgentPlayerRow | null>(null);
const transferAmount = ref(100);
const transferContext = ref<WalletTransferContextData | null>(null);
const transferContextLoading = ref(false);
/* ─── Sub-agents ─── */
const subAgentKeyword = ref('');
const subAgents = ref<AgentSubAgentRow[]>([]);
const loadingAgents = ref(false);
const subAgentTableRef = ref<TableInstance>();
/* Sub-agent expansion */
const expandedSet = ref(new Set<string>());
const subAgentPlayersMap = ref<Record<string, AgentPlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
/* Sub-agent create dialog */
const createSubVisible = ref(false);
const createSubLoading = ref(false);
const createSubForm = ref(emptyAgentSubAgentCreateForm());
const editSubVisible = ref(false);
const editSubLoading = ref(false);
const editSubForm = ref(emptyAgentSubAgentEditForm());
const creditVisible = ref(false);
const creditLoading = ref(false);
const creditTarget = ref<AgentSubAgentRow | null>(null);
const creditAmount = ref(10000);
const creditRemark = ref('');
const creditContext = ref<AgentCreditAdjustContext | null>(null);
/* ─── Computed ─── */
const availableCreditNum = computed(() => {
const n = Number(profile.value.availableCredit ?? 0);
return Number.isFinite(n) ? Math.max(0, n) : 0;
});
const initialDepositRange = computed(() => ({ min: 0, max: availableCreditNum.value }));
const filteredPlayers = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return players.value;
return players.value.filter(
(p) => p.username.toLowerCase().includes(q) || String(p.id).includes(q),
);
});
const filteredSubAgents = computed(() => {
const q = subAgentKeyword.value.trim().toLowerCase();
if (!q) return subAgents.value;
return subAgents.value.filter(
(a) => a.username.toLowerCase().includes(q) || a.userId.includes(q),
);
});
const transferAmountRange = computed(() => {
if (transferType.value === 'withdraw') {
const cap = parsePlayerAvailable(transferContext.value);
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
}
const cap = depositAmountCap(transferContext.value);
if (cap === undefined) return { min: 0.01, max: undefined as number | undefined };
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
});
const transferAmountDisabled = computed(() => {
const { max } = transferAmountRange.value;
return max === 0;
});
/** 勿用 el-input-number :max否则会静默截断到上限 */
const transferAmountExceedsCap = computed(() => {
const max = transferAmountRange.value.max;
if (max === undefined) return false;
return transferAmount.value > max;
});
const transferAmountCapError = computed(() => {
if (!transferAmountExceedsCap.value) return '';
return transferType.value === 'deposit'
? t('err.insufficient_credit')
: t('transfer.context.withdraw_exceed');
});
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
await loadPlayers();
if (canManageSubAgents.value) {
await loadSubAgents();
}
});
async function loadProfile() {
try {
const { data } = await api.get('/agent/profile');
profile.value = data.data;
} catch { /* empty */ }
}
async function loadPlayers() {
loadingPlayers.value = true;
try {
const { data } = await api.get('/agent/players');
players.value = data.data as AgentPlayerRow[];
} finally {
loadingPlayers.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;
}
async function loadSubAgents() {
loadingAgents.value = true;
try {
const { data } = await api.get('/agent/agents');
const items = data.data;
if (Array.isArray(items)) {
subAgents.value = items as AgentSubAgentRow[];
} else {
subAgents.value = [];
}
} catch {
subAgents.value = [];
} finally {
loadingAgents.value = false;
}
}
/* ─── Sub-agent expansion ─── */
async function onSubAgentExpand(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map(r => r.userId));
if (expandedSet.value.has(row.userId) && !subAgentPlayersMap.value[row.userId]) {
await loadSubAgentPlayers(row.userId);
}
}
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRef.value?.toggleRowExpansion(row);
}
async function loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${subAgentUserId}/players`);
subAgentPlayersMap.value[subAgentUserId] = data.data as AgentPlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
}
}
function getSubAgentPlayers(userId: string) {
return subAgentPlayersMap.value[userId] || [];
}
/* ─── Player CRUD ─── */
function openCreate() {
createForm.value = emptyAgentPlayerCreateForm();
createVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildAgentCreatePlayerPayload>;
try {
payload = buildAgentCreatePlayerPayload(createForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
if (payload.initialDeposit != null && payload.initialDeposit > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
createLoading.value = true;
const password = createForm.value.password;
try {
await api.post('/agent/players', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
loadPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
}
async function openEdit(row: AgentPlayerRow) {
try {
const { data } = await api.get(`/agent/players/${row.id}`);
editForm.value = editFormFromAgentDetail(data.data as AgentPlayerDetail);
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEdit() {
let payload: ReturnType<typeof buildAgentUpdatePlayerPayload>;
try {
payload = buildAgentUpdatePlayerPayload(editForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editLoading.value = true;
const newPwd = editForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/players/${editForm.value.id}`, payload);
const updated = data.data as AgentPlayerDetail;
if (newPwd) {
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
editForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
loadPlayers();
return;
}
ElMessage.success(t('msg.saved'));
editVisible.value = false;
loadPlayers();
refreshSubAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editLoading.value = false;
}
}
/* ─── Freeze ─── */
async function toggleFreeze(row: AgentPlayerRow) {
const freeze = row.status === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
t('msg.freeze_confirm_body', { action, name: row.username, extra: freeze ? t('msg.freeze_extra') : '' }),
t('msg.freeze_confirm_title', { action }),
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch { return; }
try {
await api.put(`/agent/players/${row.id}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadPlayers();
refreshSubAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: AgentPlayerRow) {
transferType.value = type;
transferTarget.value = row;
transferContext.value = null;
transferVisible.value = true;
transferContextLoading.value = true;
try {
const { data } = await api.get(`/agent/players/${row.id}/transfer-context`);
transferContext.value = data.data as WalletTransferContextData;
if (type === 'deposit') {
const cap = depositAmountCap(transferContext.value);
transferAmount.value =
cap !== undefined && cap > 0 ? Math.min(100, cap) : cap === undefined ? 100 : 0;
} else {
const cap = parsePlayerAvailable(transferContext.value);
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
transferVisible.value = false;
} finally {
transferContextLoading.value = false;
}
}
async function submitTransfer() {
if (!transferTarget.value) return;
if (transferAmount.value <= 0) { ElMessage.warning(t('msg.amount_gt_zero')); return; }
const max = transferAmountRange.value.max;
if (max !== undefined && transferAmount.value > max) {
ElMessage.warning(
transferType.value === 'deposit' ? t('err.insufficient_credit') : t('transfer.context.withdraw_exceed'),
);
return;
}
const playerId = transferTarget.value.id;
const amount = transferAmount.value;
transferLoading.value = true;
try {
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${playerId}-${Date.now()}`;
if (transferType.value === 'deposit') {
await api.post(`/agent/players/${playerId}/deposit`, { amount, requestId });
ElMessage.success(t('msg.topup_ok'));
} else {
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
loadPlayers();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
} finally {
transferLoading.value = false;
}
}
function transferTitle() {
const name = transferTarget.value?.username ?? '';
return transferType.value === 'deposit'
? t('agent_portal.transfer_title_deposit', { name })
: t('agent_portal.transfer_title_withdraw', { name });
}
/* ─── Create sub-agent ─── */
function openCreateSub() {
createSubForm.value = emptyAgentSubAgentCreateForm();
createSubVisible.value = true;
}
async function submitCreateSub() {
let payload: ReturnType<typeof buildAgentSubAgentCreatePayload>;
try {
payload = buildAgentSubAgentCreatePayload(createSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createSubLoading.value = true;
try {
await api.post('/agent/agents', payload);
ElMessage.success(t('msg.agent_created'));
createSubVisible.value = false;
loadSubAgents();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createSubLoading.value = false;
}
}
async function openEditSub(row: AgentSubAgentRow) {
try {
const { data } = await api.get(`/agent/agents/${row.userId}`);
editSubForm.value = editFormFromSubAgentDetail(data.data as AgentSubAgentDetail);
editSubVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEditSub() {
let payload: ReturnType<typeof buildAgentSubAgentUpdatePayload>;
try {
payload = buildAgentSubAgentUpdatePayload(editSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editSubLoading.value = true;
const newPwd = editSubForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/agents/${editSubForm.value.userId}`, payload);
const updated = data.data as AgentSubAgentDetail;
if (newPwd) {
editSubForm.value.managedPassword = updated.managedPassword ?? newPwd;
editSubForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editSubForm.value.managedPassword }));
loadSubAgents();
return;
}
ElMessage.success(t('msg.saved'));
editSubVisible.value = false;
loadSubAgents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editSubLoading.value = false;
}
}
function openCreditSub(row: AgentSubAgentRow) {
creditTarget.value = row;
creditAmount.value = 10000;
creditRemark.value = '';
creditContext.value = {
target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0),
}),
};
creditVisible.value = true;
}
async function submitCreditSub() {
if (!creditTarget.value) return;
if (creditAmount.value === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
if (creditAmount.value > 0 && creditAmount.value > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/agent/agents/${creditTarget.value.userId}/credit`, {
amount: creditAmount.value,
requestId: `agent-credit-${creditTarget.value.userId}-${Date.now()}`,
remark: creditRemark.value.trim() || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
loadSubAgents();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
}
async function toggleFreezeSub(row: AgentSubAgentRow) {
const accountStatus = subAgentAccountStatus(row);
const freeze = accountStatus === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
t('msg.freeze_confirm_body', { action, name: row.username, extra: freeze ? t('msg.freeze_extra') : '' }),
t('msg.freeze_confirm_title', { action }),
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch { return; }
try {
await api.put(`/agent/agents/${row.userId}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadSubAgents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Helpers ─── */
function refreshSubAgentPlayers() {
for (const uid of expandedSet.value) {
loadSubAgentPlayers(uid);
}
}
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentSubAgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
function formatTime(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `user.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
</script>
<template>
<div class="admin-list-page agent-portal-mgr">
<!-- Credit strip -->
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.used_credit') }}</span>
<span class="credit-value">{{ formatAmount(profile.usedCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.credit_limit') }}</span>
<span class="credit-value">{{ formatAmount(profile.creditLimit) }}</span>
</div>
</div>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="keyword" :placeholder="t('agent_portal.search_player_ph')" clearable style="width: 180px" />
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreate">
+ {{ t('agent_portal.create_player_btn') }}
</el-button>
</div>
<el-table v-loading="loadingPlayers" :data="filteredPlayers" stripe size="small" class="inner-table">
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }">
<template v-if="row.wallet?.availableBalance != null">
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
</el-tooltip>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" 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>
<el-button v-else size="small" link type="primary" @click="toggleFreeze(row)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab: 下级代理 (仅一级代理可见) -->
<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')">
<el-input v-model="subAgentKeyword" :placeholder="t('agent_portal.search_sub_agent_ph')" clearable style="width: 180px" />
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreateSub">
+ {{ t('agent_portal.create_tier2_btn') }}
</el-button>
</div>
<el-table
ref="subAgentTableRef"
v-loading="loadingAgents"
:data="filteredSubAgents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="onSubAgentExpand"
@row-click="onSubAgentRowClick"
>
<template #empty><div class="empty-hint">{{ t('agent_portal.no_sub_agents') || '暂无下级代理' }}</div></template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<template v-else>
<div class="expand-section-title">{{ t('nav.players') }} ({{ getSubAgentPlayers(row.userId).length }})</div>
<p class="expand-readonly-hint">{{ t('agent_portal.sub_agent_players_readonly') }}</p>
<el-table :data="getSubAgentPlayers(row.userId)" stripe size="small" class="inner-table nested-table">
<template #empty><div class="empty-hint">暂无数据</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row: p }">
<template v-if="p.wallet?.availableBalance != null">
<span>{{ formatAmount(p.wallet.availableBalance) }}</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row: p }">{{ formatTime(p.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">
{{ statusLabel(subAgentAccountStatus(row)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span>{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="280" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" type="primary" link @click="openEditSub(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-button>
<el-button
v-if="subAgentAccountStatus(row) === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreezeSub(row)"
>{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- DIALOGS -->
<!-- Create Player -->
<el-dialog v-model="createVisible" :title="t('agent_portal.create_player_dialog')" width="520px" destroy-on-close>
<el-alert
type="info" :closable="false" show-icon class="create-alert"
:title="t('agent_portal.credit_available_hint', { amount: formatAmount(profile.availableCredit) })"
/>
<el-form label-width="100px" class="create-form">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="initialDepositRange.min" :max="initialDepositRange.max" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.initial_deposit_hint') }}</div>
</el-form-item>
<el-form-item v-if="createForm.initialDeposit > 0" :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" width="480px" destroy-on-close class="user-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editForm.id }}</span>
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<div 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="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editForm.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="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- Transfer (Deposit/Withdraw) -->
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
<WalletTransferContext
:context="transferContext"
:mode="transferType"
:loading="transferContextLoading"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min"
:disabled="transferAmountDisabled"
:step="10"
:precision="2"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled || transferAmountExceedsCap" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- Create Sub-Agent -->
<el-dialog v-model="createSubVisible" :title="t('agent_portal.create_tier2_btn')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createSubForm.username" :placeholder="t('agent_portal.agent_username_ph')" />
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createSubForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createSubForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number v-model="createSubForm.creditLimit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="createSubForm.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="createSubForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="createSubForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createSubLoading" @click="submitCreateSub">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- Edit Sub-Agent -->
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editSubForm.userId }}</span>
<el-tag :type="statusTagType(editSubForm.status)" size="small">{{ statusLabel(editSubForm.status) }}</el-tag>
<el-tag size="small" type="info">L{{ editSubForm.level }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editSubForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editSubForm.managedPassword" class="password-plain">{{ editSubForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editSubForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editSubForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editSubForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ formatAmount(editSubForm.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editSubForm.availableCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editSubForm.directPlayerCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
{{ editSubForm.lastLoginAt ? formatTime(editSubForm.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editSubLoading" @click="submitEditSub">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- Adjust Sub-Agent Credit -->
<el-dialog
v-model="creditVisible"
:title="t('agent_portal.adjust_credit_dialog', { name: creditTarget?.username ?? '' })"
width="520px"
destroy-on-close
>
<AgentCreditContext
:context="creditContext"
:adjust-amount="creditAmount"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ creditTarget?.userId }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number v-model="creditAmount" :step="1000" :precision="2" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.credit_adjust_hint') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditRemark" :placeholder="t('common.optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<PlayerWalletLedgerDialog
v-model="walletLedgerVisible"
:player-id="walletLedgerPlayerId"
:player-username="walletLedgerPlayerUsername"
/>
</div>
</template>
<style scoped>
/* ─── Credit strip ─── */
.credit-strip {
display: flex; align-items: center; gap: 20px;
padding: 14px 18px; margin-bottom: 16px;
border-radius: 12px; border: 1px solid #1e1e1e;
background: linear-gradient(135deg, rgba(30,30,30,.6), rgba(20,20,20,.4));
}
.credit-item { display: flex; flex-direction: column; gap: 4px; }
.credit-label { font-size: 12px; color: #666; }
.credit-value { font-size: 18px; font-weight: 700; color: #e0e0e0; font-variant-numeric: tabular-nums; }
.credit-value.c-green { color: #67c23a; }
.credit-divider { width: 1px; height: 32px; background: #2a2a2a; }
/* ─── Tabs ─── */
.portal-top-tabs { margin-bottom: 8px; }
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
/* ─── Inner content ─── */
.inner-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
.inner-table { border-radius: 6px; }
.expandable-table :deep(.row-expandable) { cursor: pointer; }
.action-btns {
display: flex; align-items: center; justify-content: center;
gap: 4px; flex-wrap: nowrap; white-space: nowrap;
}
/* ─── Expansion ─── */
.expand-panel { padding: 4px 16px 8px; }
.expand-loading { text-align: center; padding: 16px; color: #888; font-size: 13px; }
.expand-section-title { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 6px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
/* ─── Shared ─── */
.field-hint { margin-top: 6px; font-size: 12px; color: #666; line-height: 1.4; }
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
.create-alert { margin-bottom: 16px; }
.create-form { margin-top: 4px; }
/* ─── Edit dialog ─── */
.edit-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; font-size: 12px; color: #666; }
.password-mgmt-block {
margin: 8px 0 16px; padding: 12px 14px;
border-radius: 8px; border: 1px solid #1e1e1e;
background: rgba(255,255,255,.02);
}
.block-title { font-size: 12px; font-weight: 600; color: #888; margin-bottom: 10px; }
.password-plain { font-family: ui-monospace, monospace; color: #e0e0e0; }
.password-empty { color: #555; }
.block-hint { margin: -4px 0 10px; }
.edit-stats { margin-top: 8px; }
.compact-edit-form :deep(.el-form-item) { margin-bottom: 10px; }
</style>
<style>
/* 代理端冻结按钮:与管理端一致的金黄渐变 */
.agent-portal-mgr .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255,255,255,.12) inset, 0 1px 6px rgba(0,0,0,.35) !important;
text-shadow: 0 1px 1px rgba(0,0,0,.2);
}
.agent-portal-mgr .el-button.is-link.el-button--warning:hover,
.agent-portal-mgr .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>