feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,28 +1,101 @@
|
||||
import axios from 'axios';
|
||||
import router from './router';
|
||||
import { clearStaffSession } from './stores/auth';
|
||||
import { clearStaffSession, reconcileStaffSessionFromToken, useAuthStore } from './stores/auth';
|
||||
import { ensureStaffSession, resetStaffSessionHydration } from './utils/session-hydrate';
|
||||
|
||||
const api = axios.create({ baseURL: '/api' });
|
||||
|
||||
let handling401 = false;
|
||||
let handling403Portal = false;
|
||||
|
||||
const PORTAL_MISMATCH_MESSAGES = new Set(['Admin access only', 'Agent access only']);
|
||||
|
||||
function requestPath(config: { url?: string; baseURL?: string } | undefined): string {
|
||||
if (!config?.url) return '';
|
||||
const base = config.baseURL ?? '';
|
||||
return `${base}${config.url}`;
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const t = localStorage.getItem('manage_token');
|
||||
if (t) config.headers.Authorization = `Bearer ${t}`;
|
||||
|
||||
reconcileStaffSessionFromToken();
|
||||
const auth = useAuthStore();
|
||||
const path = requestPath(config);
|
||||
|
||||
if (path.includes('/admin/') && auth.isAgent.value) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('Agent portal blocked admin API'), {
|
||||
isPortalMismatch: true,
|
||||
blockedPath: path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.includes('/agent/') && auth.isAdmin.value) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('Admin portal blocked agent API'), {
|
||||
isPortalMismatch: true,
|
||||
blockedPath: path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err) => {
|
||||
if (err.isPortalMismatch) {
|
||||
await ensureStaffSession();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/');
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (err.response?.status === 401 && !handling401) {
|
||||
handling401 = true;
|
||||
clearStaffSession();
|
||||
resetStaffSessionHydration();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/login');
|
||||
}
|
||||
handling401 = false;
|
||||
}
|
||||
|
||||
if (
|
||||
err.response?.status === 403 &&
|
||||
!handling403Portal &&
|
||||
PORTAL_MISMATCH_MESSAGES.has(err.response?.data?.message)
|
||||
) {
|
||||
handling403Portal = true;
|
||||
await ensureStaffSession();
|
||||
handling403Portal = false;
|
||||
|
||||
const auth = useAuthStore();
|
||||
const path = requestPath(err.config);
|
||||
const isAdminApi = path.includes('/admin/');
|
||||
const isAgentApi = path.includes('/agent/');
|
||||
|
||||
if ((isAdminApi && auth.isAgent.value) || (isAgentApi && auth.isAdmin.value)) {
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/');
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (err.config) {
|
||||
return api.request(err.config);
|
||||
}
|
||||
|
||||
clearStaffSession();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/login');
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
137
apps/admin/src/components/AgentCreditContext.vue
Normal file
137
apps/admin/src/components/AgentCreditContext.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull, shouldCompactAmount } from '../utils/format-amount';
|
||||
import {
|
||||
maxCreditIncreaseAmount,
|
||||
projectedCreditLimit,
|
||||
type AgentCreditAdjustContext,
|
||||
} from '../utils/agent-credit-context';
|
||||
|
||||
const props = defineProps<{
|
||||
context: AgentCreditAdjustContext | null;
|
||||
loading?: boolean;
|
||||
adjustAmount?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const maxIncrease = computed(() => maxCreditIncreaseAmount(props.context));
|
||||
const afterLimit = computed(() => projectedCreditLimit(props.context, props.adjustAmount ?? 0));
|
||||
|
||||
function fmt(value: string | null | undefined) {
|
||||
if (value == null || value === '') return '—';
|
||||
return formatAmount(value);
|
||||
}
|
||||
|
||||
function fmtFull(value: string | null | undefined) {
|
||||
if (value == null || value === '') return '';
|
||||
return shouldCompactAmount(value) ? formatAmountFull(value) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="credit-context">
|
||||
<div v-if="context?.target" class="credit-context-section">
|
||||
<div class="credit-context-title">{{ t('credit.context.target_section') }}</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item :label="t('user.col.username')">
|
||||
{{ context.target.username }}
|
||||
<span v-if="context.target.level != null" class="level-tag">L{{ context.target.level }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||||
<span class="c-green">{{ fmt(context.target.availableCredit) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(context.target.creditLimit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(context.target.usedCredit) }}</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="context.target.directPlayerLiability != null"
|
||||
:label="t('credit.context.direct_liability')"
|
||||
>
|
||||
{{ fmt(context.target.directPlayerLiability) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="context.target.childAgentExposure != null"
|
||||
:label="t('credit.context.child_exposure')"
|
||||
>
|
||||
{{ fmt(context.target.childAgentExposure) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-if="context?.parent" class="credit-context-section">
|
||||
<div class="credit-context-title">
|
||||
{{ t('credit.context.parent_section', { name: context.parent.username }) }}
|
||||
</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(context.parent.creditLimit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(context.parent.usedCredit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')" :span="2">
|
||||
<span class="c-green">{{ fmt(context.parent.availableCredit) }}</span>
|
||||
<span v-if="fmtFull(context.parent.availableCredit)" class="amount-full-hint">
|
||||
({{ fmtFull(context.parent.availableCredit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('credit.context.max_increase')" :span="2">
|
||||
<span class="c-amber">{{ fmt(String(maxIncrease ?? 0)) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-else-if="context?.target && !loading"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="credit-context-alert"
|
||||
:title="t('credit.context.no_parent')"
|
||||
/>
|
||||
|
||||
<div v-if="afterLimit != null && adjustAmount !== 0" class="credit-context-preview">
|
||||
{{ t('credit.context.after_adjust') }}:
|
||||
<span class="c-amber">{{ fmt(afterLimit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credit-context {
|
||||
margin-bottom: 16px;
|
||||
min-height: 48px;
|
||||
}
|
||||
.credit-context-section + .credit-context-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.credit-context-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.credit-context-alert {
|
||||
margin-top: 0;
|
||||
}
|
||||
.credit-context-preview {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
.level-tag {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
.c-green {
|
||||
color: #67c23a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.c-amber {
|
||||
color: #e6a23c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.amount-full-hint {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
116
apps/admin/src/components/WalletTransferContext.vue
Normal file
116
apps/admin/src/components/WalletTransferContext.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull, shouldCompactAmount } from '../utils/format-amount';
|
||||
import { depositAmountCap, type WalletTransferContext } from '../utils/wallet-transfer-context';
|
||||
|
||||
const props = defineProps<{
|
||||
context: WalletTransferContext | null;
|
||||
mode: 'deposit' | 'withdraw';
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const credit = computed(() => props.context?.credit ?? null);
|
||||
const player = computed(() => props.context?.player ?? null);
|
||||
|
||||
const depositCapText = computed(() => {
|
||||
if (!props.context?.credit) return '—';
|
||||
const cap = depositAmountCap(props.context);
|
||||
if (cap === undefined) return t('transfer.context.unlimited');
|
||||
return formatAmount(String(cap));
|
||||
});
|
||||
|
||||
function fmt(value: string | null | undefined) {
|
||||
if (value == null || value === '') return '—';
|
||||
return formatAmount(value);
|
||||
}
|
||||
|
||||
function fmtFull(value: string | null | undefined) {
|
||||
if (value == null || value === '') return '';
|
||||
return shouldCompactAmount(value) ? formatAmountFull(value) : '';
|
||||
}
|
||||
|
||||
function limitLabel(value: string | null) {
|
||||
return value ? fmt(value) : t('transfer.context.unlimited');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="transfer-context">
|
||||
<div v-if="player" class="transfer-context-section">
|
||||
<div class="transfer-context-title">{{ t('transfer.context.player_section') }}</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ player.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.available')">
|
||||
<span class="c-green">{{ fmt(player.availableBalance) }}</span>
|
||||
<span v-if="fmtFull(player.availableBalance)" class="amount-full-hint">
|
||||
({{ fmtFull(player.availableBalance) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="mode === 'withdraw'" :label="t('transfer.context.withdrawable')" :span="2">
|
||||
{{ fmt(player.availableBalance) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="Number(player.frozenBalance) > 0" :label="t('user.field.frozen_balance')">
|
||||
{{ fmt(player.frozenBalance) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'deposit' && credit" class="transfer-context-section">
|
||||
<div class="transfer-context-title">
|
||||
{{ t('transfer.context.agent_section', { name: credit.agentUsername, level: credit.agentLevel }) }}
|
||||
</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(credit.creditLimit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(credit.usedCredit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||||
<span class="c-green">{{ fmt(credit.availableCredit) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('transfer.context.deposit_cap')">
|
||||
<span class="c-amber">{{ depositCapText }}</span>
|
||||
</el-descriptions-item>
|
||||
<template v-if="credit.appliesDepositLimits">
|
||||
<el-descriptions-item :label="t('agent.field.max_single_deposit')">
|
||||
{{ limitLabel(credit.maxSingleDeposit) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.max_daily_deposit')">
|
||||
{{ limitLabel(credit.maxDailyDeposit) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="credit.dailyDepositUsed != null" :label="t('transfer.context.daily_used')" :span="2">
|
||||
{{ fmt(credit.dailyDepositUsed) }}
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item v-else :span="2">
|
||||
<span class="field-hint">{{ t('transfer.context.admin_credit_only') }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-else-if="mode === 'deposit' && player && !credit && !loading"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="transfer-context-alert"
|
||||
:title="t('transfer.context.no_agent')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-context { margin-bottom: 16px; min-height: 48px; }
|
||||
.transfer-context-section + .transfer-context-section { margin-top: 12px; }
|
||||
.transfer-context-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.transfer-context-alert { margin-bottom: 0; }
|
||||
.c-green { color: #67c23a; font-weight: 600; }
|
||||
.c-amber { color: #e6a23c; font-weight: 600; }
|
||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||
.field-hint { font-size: 12px; color: #666; }
|
||||
</style>
|
||||
132
apps/admin/src/composables/useAdminPlayerTransfer.ts
Normal file
132
apps/admin/src/composables/useAdminPlayerTransfer.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '../api';
|
||||
import { useAdminLocale } from './useAdminLocale';
|
||||
import {
|
||||
depositAmountCap,
|
||||
parsePlayerAvailable,
|
||||
type WalletTransferContext,
|
||||
} from '../utils/wallet-transfer-context';
|
||||
|
||||
export type PlayerTransferTarget = { id: string; username?: string };
|
||||
|
||||
export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const transferVisible = ref(false);
|
||||
const transferLoading = ref(false);
|
||||
const transferType = ref<'deposit' | 'withdraw'>('deposit');
|
||||
const transferTarget = ref<PlayerTransferTarget | null>(null);
|
||||
const transferAmount = ref(100);
|
||||
const transferRemark = ref('');
|
||||
const transferContext = ref<WalletTransferContext | null>(null);
|
||||
const transferContextLoading = ref(false);
|
||||
|
||||
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(() => transferAmountRange.value.max === 0);
|
||||
|
||||
function transferTitle() {
|
||||
const name = transferTarget.value?.username ?? transferTarget.value?.id ?? '';
|
||||
return transferType.value === 'deposit'
|
||||
? t('agent_portal.transfer_title_deposit', { name })
|
||||
: t('agent_portal.transfer_title_withdraw', { name });
|
||||
}
|
||||
|
||||
async function openTransfer(type: 'deposit' | 'withdraw', row: PlayerTransferTarget) {
|
||||
transferType.value = type;
|
||||
transferTarget.value = row;
|
||||
transferContext.value = null;
|
||||
transferAmount.value = 100;
|
||||
transferRemark.value = type === 'deposit' ? t('user.deposit_remark_default') : '';
|
||||
transferVisible.value = true;
|
||||
transferContextLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/wallet/transfer-context/${row.id}`);
|
||||
transferContext.value = data.data as WalletTransferContext;
|
||||
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 userId = transferTarget.value.id;
|
||||
const amount = transferAmount.value;
|
||||
transferLoading.value = true;
|
||||
try {
|
||||
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${userId}-${Date.now()}`;
|
||||
const endpoint =
|
||||
transferType.value === 'deposit' ? '/admin/wallet/deposit' : '/admin/wallet/withdraw';
|
||||
await api.post(endpoint, {
|
||||
userId,
|
||||
amount,
|
||||
remark: transferRemark.value || undefined,
|
||||
requestId,
|
||||
});
|
||||
ElMessage.success(
|
||||
transferType.value === 'deposit' ? t('msg.topup_ok') : t('msg.withdraw_ok'),
|
||||
);
|
||||
transferVisible.value = false;
|
||||
await onSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
const fallback =
|
||||
transferType.value === 'deposit' ? t('msg.topup_failed') : t('msg.transfer_failed');
|
||||
ElMessage.error(err.response?.data?.error ?? fallback);
|
||||
} finally {
|
||||
transferLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transferVisible,
|
||||
transferLoading,
|
||||
transferType,
|
||||
transferTarget,
|
||||
transferAmount,
|
||||
transferRemark,
|
||||
transferContext,
|
||||
transferContextLoading,
|
||||
transferAmountRange,
|
||||
transferAmountDisabled,
|
||||
transferTitle,
|
||||
openTransfer,
|
||||
submitTransfer,
|
||||
};
|
||||
}
|
||||
@@ -26,12 +26,14 @@ const zh: Record<string, string> = {
|
||||
'login.quick_label': '快速登录(调试)',
|
||||
'login.quick_admin': '管理员',
|
||||
'login.quick_agent': '一级代理',
|
||||
'login.quick_agent2': '二级代理',
|
||||
'login.captcha_ph': '验证码',
|
||||
'login.captcha_refresh': '点击刷新',
|
||||
|
||||
'nav.dashboard': '概览',
|
||||
'nav.users': '玩家管理',
|
||||
'nav.agents': '代理管理',
|
||||
'nav.agents_players': '代理&玩家',
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.outrights': '优胜冠军',
|
||||
'nav.bets': '注单管理',
|
||||
@@ -49,6 +51,8 @@ const zh: Record<string, string> = {
|
||||
'breadcrumb.outright_edit': '编辑优胜冠军',
|
||||
'role.admin': '系统管理员',
|
||||
'role.agent': '代理账号',
|
||||
'role.tier1_agent': '一级代理',
|
||||
'role.tier2_agent': '二级代理',
|
||||
'logout': '退出',
|
||||
'lang': '语言',
|
||||
'portal.admin': '平台后台',
|
||||
@@ -135,11 +139,21 @@ const zh: Record<string, string> = {
|
||||
'page.audit.desc': '记录所有管理员操作行为',
|
||||
'page.settlement.title': '赛事结算',
|
||||
'page.agent_dash.title': '代理概览',
|
||||
'page.agent_dash.desc': '实时数据总览',
|
||||
'page.agent_dash.desc': '下线经营概况与分布',
|
||||
'agent_dash.load_error_hint': '无法加载概览数据,请检查网络或重新登录后再试。',
|
||||
'agent_dash.board_hint': '一屏查看下线经营趋势与分布',
|
||||
'agent_dash.kpi_players': '直属玩家 / 下级代理',
|
||||
'agent_dash.kpi_pending_sub': '{bets} 单待结算',
|
||||
'agent_dash.pie_credit': '授信占用',
|
||||
'agent_dash.pie_players': '直属玩家',
|
||||
'agent_dash.credit_available': '可用额度',
|
||||
'agent_dash.credit_used': '已用额度',
|
||||
'agent_dash.liability_direct': '玩家余额占用',
|
||||
'agent_dash.liability_child': '下级代理占用',
|
||||
'page.agent_players.title': '直属玩家',
|
||||
'page.agent_players.desc': '管理你名下的直属玩家',
|
||||
'page.agent_sub.title': '下级代理',
|
||||
'page.agent_sub.desc': '仅一级代理可见',
|
||||
'page.agent_sub.desc': '管理二级代理账号与授信分配',
|
||||
'page.agent_bets.title': '注单查询',
|
||||
'page.agent_bets.desc': '下级玩家的全部投注记录',
|
||||
|
||||
@@ -190,12 +204,14 @@ const en: Record<string, string> = {
|
||||
'login.quick_label': 'Quick sign-in (debug)',
|
||||
'login.quick_admin': 'Admin',
|
||||
'login.quick_agent': 'Tier-1 agent',
|
||||
'login.quick_agent2': 'Tier-2 agent',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Click to refresh',
|
||||
|
||||
'nav.dashboard': 'Overview',
|
||||
'nav.users': 'Players',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.agents_players': 'Agents & Players',
|
||||
'nav.matches': 'Matches',
|
||||
'nav.outrights': 'Outrights',
|
||||
'nav.bets': 'Bets',
|
||||
@@ -213,6 +229,8 @@ const en: Record<string, string> = {
|
||||
'breadcrumb.outright_edit': 'Edit outright',
|
||||
'role.admin': 'Administrator',
|
||||
'role.agent': 'Agent',
|
||||
'role.tier1_agent': 'Tier-1 Agent',
|
||||
'role.tier2_agent': 'Tier-2 Agent',
|
||||
'logout': 'Logout',
|
||||
'lang': 'Language',
|
||||
'portal.admin': 'Platform Admin',
|
||||
@@ -299,11 +317,21 @@ const en: Record<string, string> = {
|
||||
'page.audit.desc': 'Administrator action history',
|
||||
'page.settlement.title': 'Settlement',
|
||||
'page.agent_dash.title': 'Agent overview',
|
||||
'page.agent_dash.desc': 'Live summary',
|
||||
'page.agent_dash.desc': 'Downline performance at a glance',
|
||||
'agent_dash.load_error_hint': 'Could not load overview. Check your network or sign in again.',
|
||||
'agent_dash.board_hint': 'Trends and distribution for your downline',
|
||||
'agent_dash.kpi_players': 'Direct players / Sub-agents',
|
||||
'agent_dash.kpi_pending_sub': '{bets} pending bets',
|
||||
'agent_dash.pie_credit': 'Credit usage',
|
||||
'agent_dash.pie_players': 'Direct players',
|
||||
'agent_dash.credit_available': 'Available',
|
||||
'agent_dash.credit_used': 'Used',
|
||||
'agent_dash.liability_direct': 'Player balance',
|
||||
'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.desc': 'Tier-1 agents only',
|
||||
'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',
|
||||
|
||||
@@ -354,12 +382,14 @@ const ms: Record<string, string> = {
|
||||
'login.quick_label': 'Log masuk pantas (debug)',
|
||||
'login.quick_admin': 'Admin',
|
||||
'login.quick_agent': 'Ejen peringkat 1',
|
||||
'login.quick_agent2': 'Ejen peringkat 2',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Klik untuk muat semula',
|
||||
|
||||
'nav.dashboard': 'Gambaran',
|
||||
'nav.users': 'Pemain',
|
||||
'nav.agents': 'Ejen',
|
||||
'nav.agents_players': 'Ejen & Pemain',
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.outrights': 'Juara',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
@@ -377,6 +407,8 @@ const ms: Record<string, string> = {
|
||||
'breadcrumb.outright_edit': 'Edit juara',
|
||||
'role.admin': 'Pentadbir',
|
||||
'role.agent': 'Ejen',
|
||||
'role.tier1_agent': 'Ejen Peringkat 1',
|
||||
'role.tier2_agent': 'Ejen Peringkat 2',
|
||||
'logout': 'Log keluar',
|
||||
'lang': 'Bahasa',
|
||||
'portal.admin': 'Admin Platform',
|
||||
@@ -463,11 +495,21 @@ const ms: Record<string, string> = {
|
||||
'page.audit.desc': 'Sejarah tindakan pentadbir',
|
||||
'page.settlement.title': 'Penyelesaian',
|
||||
'page.agent_dash.title': 'Gambaran ejen',
|
||||
'page.agent_dash.desc': 'Ringkasan langsung',
|
||||
'page.agent_dash.desc': 'Prestasi downline sepintas lalu',
|
||||
'agent_dash.load_error_hint': 'Gagal memuatkan gambaran. Semak rangkaian atau log masuk semula.',
|
||||
'agent_dash.board_hint': 'Trend dan taburan downline anda',
|
||||
'agent_dash.kpi_players': 'Pemain terus / Ejen bawahan',
|
||||
'agent_dash.kpi_pending_sub': '{bets} pertaruhan belum selesai',
|
||||
'agent_dash.pie_credit': 'Penggunaan kredit',
|
||||
'agent_dash.pie_players': 'Pemain terus',
|
||||
'agent_dash.credit_available': 'Tersedia',
|
||||
'agent_dash.credit_used': 'Digunakan',
|
||||
'agent_dash.liability_direct': 'Baki pemain',
|
||||
'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.desc': 'Ejen peringkat 1 sahaja',
|
||||
'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',
|
||||
|
||||
|
||||
@@ -94,13 +94,17 @@ 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.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
|
||||
|
||||
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
||||
'agent.create_sub': 'Cipta sub-ejen',
|
||||
'agent.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini',
|
||||
'agent.filter.username_ph': 'Nama pengguna',
|
||||
'agent.col.level': 'Peringkat',
|
||||
'agent.col.credit': 'Had / Digunakan / Tersedia',
|
||||
'agent.col.direct_players': 'Pemain terus',
|
||||
'agent.col.sub_agents': 'Sub-ejen',
|
||||
'agent.col.cashback': 'Kadar rebat',
|
||||
'agent.col.phone': 'Telefon',
|
||||
'agent.col.created': 'Dicipta',
|
||||
@@ -359,9 +363,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
||||
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
||||
'err.credit_negative': 'Had kredit tidak boleh negatif',
|
||||
'err.insufficient_credit': 'Kredit tersedia tidak mencukupi. Kurangkan jumlah atau minta penambahan had.',
|
||||
'err.kickoff_required': 'Sila isi masa mula',
|
||||
'err.team_country_required': 'Pilih pasukan tuan rumah dan pelawat',
|
||||
'err.team_country_required': 'Pilih pasukan tuan rumah dan pelawat',
|
||||
'err.teams_required': 'Isi nama pasukan tuan rumah dan pelawat (ZH atau EN)',
|
||||
'err.teams_same': 'Pasukan tuan rumah dan pelawat mesti berbeza',
|
||||
'err.league_required': 'Sila isi nama liga',
|
||||
@@ -433,6 +437,18 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent_portal.withdraw_btn_label': 'Keluarkan',
|
||||
'agent_portal.transfer_title_deposit': 'Tambah baki {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Keluarkan dari {name}',
|
||||
'agent_portal.create_player_dialog': 'Pemain baharu',
|
||||
'agent_portal.edit_player_dialog': 'Edit pemain langsung',
|
||||
'agent_portal.credit_available_hint': 'Kredit tersedia: {amount} (tambah baki ditolak dari had)',
|
||||
'agent_portal.initial_deposit_hint': 'Pilihan. Tambah baki awal dari kredit anda semasa pendaftaran',
|
||||
'agent_portal.search_player_ph': 'Nama pengguna atau ID',
|
||||
'agent_portal.no_players': 'Tiada pemain langsung. Klik butang di atas untuk cipta.',
|
||||
'agent_portal.search_sub_agent_ph': 'Nama pengguna atau ID',
|
||||
'agent_portal.no_sub_agents': 'Tiada ejen peringkat 2. Klik butang di atas untuk cipta.',
|
||||
'agent_portal.create_sub_agent_dialog': 'Ejen peringkat 2 baharu',
|
||||
'agent_portal.sub_agent_credit_hint': 'Kredit awal diperuntukkan dari had tersedia anda',
|
||||
'agent_portal.adjust_credit_dialog': 'Laraskan kredit {name}',
|
||||
'agent_portal.credit_adjust_hint': 'Positif untuk tambah, negatif untuk kurangkan',
|
||||
'msg.agent_sub_created': 'Sub-ejen dicipta',
|
||||
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
||||
|
||||
|
||||
@@ -94,13 +94,17 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.field.account_type': '账号类型',
|
||||
'user.type.player': '玩家',
|
||||
'user.type.tier1_agent': '一级代理',
|
||||
'user.type.sub_agent': '二级代理',
|
||||
'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分',
|
||||
|
||||
'agent.create_btn': '+ 新建一级代理',
|
||||
'agent.create_sub': '创建二级代理',
|
||||
'agent.hint.creating_under_agent': '在此代理下创建账号',
|
||||
'agent.filter.username_ph': '用户名',
|
||||
'agent.col.level': '层级',
|
||||
'agent.col.credit': '授信 / 已用 / 可用',
|
||||
'agent.col.direct_players': '直属玩家',
|
||||
'agent.col.sub_agents': '下级代理',
|
||||
'agent.col.cashback': '返水率',
|
||||
'agent.col.phone': '手机',
|
||||
'agent.col.created': '创建时间',
|
||||
@@ -118,6 +122,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent.field.sub_agent_exposure': '下级代理敞口',
|
||||
'agent.hint.credit_limit': '代理可向直属玩家上分的总额度上限',
|
||||
'agent.hint.cashback_example': '例如 0.01 表示 1%',
|
||||
'agent.field.max_single_deposit': '单笔上分限额',
|
||||
'agent.field.max_daily_deposit': '日上分限额',
|
||||
'agent.hint.deposit_limit_empty': '0 表示不限;下级代理不能超过上级设置',
|
||||
'agent.hint.credit_adjust': '正数为增加授信,负数为减少',
|
||||
'agent.hint.credit_remark': '选填,写入额度流水',
|
||||
'agent.section.credit_log': '最近额度变动',
|
||||
@@ -131,6 +138,18 @@ 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.msg.cascade_freeze_done': '已停用代理并冻结其直属玩家',
|
||||
'agent.msg.freeze_done': '已{action}',
|
||||
|
||||
'match.create_btn': '+ 新增赛事',
|
||||
'match.create_fixture_btn': '+ 新增单场',
|
||||
@@ -361,6 +380,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'err.password_min': '密码至少 8 位',
|
||||
'err.password_mismatch': '两次密码不一致',
|
||||
'err.credit_negative': '授信额度不能为负',
|
||||
'err.insufficient_credit': '可用授信不足,请减少上分金额或联系上级调额',
|
||||
'err.kickoff_required': '请填写开赛时间',
|
||||
'err.team_country_required': '请选择主客队',
|
||||
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
|
||||
@@ -466,6 +486,36 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent_portal.withdraw_btn_label': '下分',
|
||||
'agent_portal.transfer_title_deposit': '给 {name} 上分',
|
||||
'agent_portal.transfer_title_withdraw': '从 {name} 下分',
|
||||
'transfer.context.player_section': '玩家余额',
|
||||
'transfer.context.agent_section': '授信代理 · {name}(L{level})',
|
||||
'transfer.context.withdrawable': '可下分金额',
|
||||
'transfer.context.deposit_cap': '本次最多可上分',
|
||||
'transfer.context.daily_used': '今日已上分',
|
||||
'transfer.context.unlimited': '不限',
|
||||
'transfer.context.no_agent': '平台直属玩家,上分不受代理授信约束',
|
||||
'transfer.context.admin_credit_only': '管理员上分仅受上级可用授信约束,不受单笔/日限',
|
||||
'transfer.context.withdraw_exceed': '下分金额不能超过玩家可用余额',
|
||||
'credit.context.target_section': '目标代理授信',
|
||||
'credit.context.parent_section': '上级代理 · {name}',
|
||||
'credit.context.max_increase': '最多可增加授信',
|
||||
'credit.context.no_parent': '一级代理由平台直管,增信不受上级约束',
|
||||
'credit.context.after_adjust': '调整后授信额度',
|
||||
'credit.context.direct_liability': '直属玩家占用',
|
||||
'credit.context.child_exposure': '下级代理占用',
|
||||
'credit.context.acting_agent': '当前代理',
|
||||
'agent_portal.create_player_dialog': '新建直属玩家',
|
||||
'agent_portal.edit_player_dialog': '编辑直属玩家',
|
||||
'agent_portal.credit_available_hint': '当前可用授信:{amount}(上分将从授信中扣除)',
|
||||
'agent_portal.sub_agent_players_readonly': '以下为该二级代理直属玩家,仅可查看;开户、上分等操作由二级代理自行处理。',
|
||||
'agent_portal.initial_deposit_hint': '可选。开户时从您的授信中给玩家上分,不能超过可用授信',
|
||||
'agent_portal.search_player_ph': '用户名或 ID',
|
||||
'agent_portal.no_players': '暂无直属玩家,点击右上角创建',
|
||||
'agent_portal.search_sub_agent_ph': '用户名或 ID',
|
||||
'agent_portal.no_sub_agents': '暂无二级代理,点击右上角创建',
|
||||
'agent_portal.create_sub_agent_dialog': '新建二级代理',
|
||||
'agent_portal.sub_agent_credit_hint': '初始授信从您的可用额度中划拨,不能超过可用授信',
|
||||
'agent_portal.adjust_credit_dialog': '调整 {name} 授信',
|
||||
'agent_portal.credit_adjust_hint': '正数为增加授信,负数为减少授信',
|
||||
'msg.agent_sub_created': '下级代理已创建',
|
||||
'msg.withdraw_ok': '下分成功',
|
||||
|
||||
@@ -728,13 +778,17 @@ 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.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': 'Create sub-agent',
|
||||
'agent.hint.creating_under_agent': 'Create account under this agent',
|
||||
'agent.filter.username_ph': 'Username',
|
||||
'agent.col.level': 'Level',
|
||||
'agent.col.credit': 'Limit / Used / Available',
|
||||
'agent.col.direct_players': 'Direct players',
|
||||
'agent.col.sub_agents': 'Sub-agents',
|
||||
'agent.col.cashback': 'Cashback rate',
|
||||
'agent.col.phone': 'Phone',
|
||||
'agent.col.created': 'Created',
|
||||
@@ -752,6 +806,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent.field.sub_agent_exposure': 'Sub-agent exposure',
|
||||
'agent.hint.credit_limit': 'Max total top-up capacity for direct players',
|
||||
'agent.hint.cashback_example': 'e.g. 0.01 = 1%',
|
||||
'agent.field.max_single_deposit': 'Max single top-up',
|
||||
'agent.field.max_daily_deposit': 'Max daily top-up',
|
||||
'agent.hint.deposit_limit_empty': '0 = unlimited; sub-agents cannot exceed parent limits',
|
||||
'agent.hint.credit_adjust': 'Positive increases, negative decreases',
|
||||
'agent.hint.credit_remark': 'Optional, written to credit ledger',
|
||||
'agent.section.credit_log': 'Recent credit changes',
|
||||
@@ -765,6 +822,18 @@ 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.msg.cascade_freeze_done': 'Agent suspended and direct players frozen',
|
||||
'agent.msg.freeze_done': '{action} completed',
|
||||
|
||||
'match.create_btn': '+ New tournament',
|
||||
'match.create_fixture_btn': '+ Add fixture',
|
||||
@@ -995,6 +1064,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'err.password_min': 'Password must be at least 8 characters',
|
||||
'err.password_mismatch': 'Passwords do not match',
|
||||
'err.credit_negative': 'Credit limit cannot be negative',
|
||||
'err.insufficient_credit': 'Insufficient available credit. Reduce the amount or request a limit increase.',
|
||||
'err.kickoff_required': 'Kickoff time is required',
|
||||
'err.team_country_required': 'Select home and away teams',
|
||||
'err.teams_required': 'Enter home and away team names (ZH or EN)',
|
||||
@@ -1101,6 +1171,36 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent_portal.withdraw_btn_label': 'Withdraw',
|
||||
'agent_portal.transfer_title_deposit': 'Top up {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Withdraw from {name}',
|
||||
'transfer.context.player_section': 'Player balance',
|
||||
'transfer.context.agent_section': 'Credit agent · {name} (L{level})',
|
||||
'transfer.context.withdrawable': 'Withdrawable',
|
||||
'transfer.context.deposit_cap': 'Max top-up this time',
|
||||
'transfer.context.daily_used': 'Topped up today',
|
||||
'transfer.context.unlimited': 'No limit',
|
||||
'transfer.context.no_agent': 'Platform-direct player — not limited by agent credit',
|
||||
'transfer.context.admin_credit_only': 'Admin top-up is capped by parent available credit only (not single/daily limits)',
|
||||
'transfer.context.withdraw_exceed': 'Withdrawal cannot exceed player available balance',
|
||||
'credit.context.target_section': 'Target agent credit',
|
||||
'credit.context.parent_section': 'Parent agent · {name}',
|
||||
'credit.context.max_increase': 'Max increase',
|
||||
'credit.context.no_parent': 'Tier-1 agents are platform-managed; increases are not capped by a parent',
|
||||
'credit.context.after_adjust': 'Credit limit after adjustment',
|
||||
'credit.context.direct_liability': 'Direct player exposure',
|
||||
'credit.context.child_exposure': 'Sub-agent exposure',
|
||||
'credit.context.acting_agent': 'Current agent',
|
||||
'agent_portal.create_player_dialog': 'New direct player',
|
||||
'agent_portal.edit_player_dialog': 'Edit direct player',
|
||||
'agent_portal.credit_available_hint': 'Available credit: {amount} (top-ups deduct from your limit)',
|
||||
'agent_portal.sub_agent_players_readonly': 'Players under this sub-agent are read-only here. Account opening and top-ups are handled by the sub-agent.',
|
||||
'agent_portal.initial_deposit_hint': 'Optional. Initial top-up from your credit at account creation',
|
||||
'agent_portal.search_player_ph': 'Username or ID',
|
||||
'agent_portal.no_players': 'No direct players yet. Use the button above to create one.',
|
||||
'agent_portal.search_sub_agent_ph': 'Username or ID',
|
||||
'agent_portal.no_sub_agents': 'No tier-2 agents yet. Use the button above to create one.',
|
||||
'agent_portal.create_sub_agent_dialog': 'New tier-2 agent',
|
||||
'agent_portal.sub_agent_credit_hint': 'Initial credit is allocated from your available limit',
|
||||
'agent_portal.adjust_credit_dialog': 'Adjust credit for {name}',
|
||||
'agent_portal.credit_adjust_hint': 'Positive to increase, negative to decrease',
|
||||
'msg.agent_sub_created': 'Sub-agent created',
|
||||
'msg.withdraw_ok': 'Withdrawal successful',
|
||||
|
||||
|
||||
@@ -14,3 +14,17 @@ export function resolveFormError(e: unknown, t: (key: string) => string): string
|
||||
if (e instanceof Error && e.message.startsWith('err.')) return t(e.message);
|
||||
return t('msg.form_invalid');
|
||||
}
|
||||
|
||||
/** 从 API 错误响应提取可读文案(Nest 全局过滤器返回 `error` 字段) */
|
||||
export function resolveApiError(
|
||||
err: unknown,
|
||||
t: (key: string) => string,
|
||||
fallbackKey = 'msg.save_failed',
|
||||
): string {
|
||||
const data = (err as { response?: { data?: { error?: string | string[]; message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
const raw = data?.error ?? data?.message;
|
||||
if (Array.isArray(raw)) return raw.join(';');
|
||||
if (typeof raw === 'string' && raw.trim()) return raw;
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
@@ -17,19 +17,17 @@ const isMobileNav = ref(false);
|
||||
const adminMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/users', label: t('nav.users') },
|
||||
{ path: '/agents', label: t('nav.agents') },
|
||||
{ path: '/users', label: t('nav.agents_players') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/contents', label: t('nav.contents') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
]);
|
||||
|
||||
const agentMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/my-players', label: t('nav.players') },
|
||||
{ path: '/my-players', label: t('nav.agents_players') },
|
||||
{ path: '/my-bets', label: t('nav.myBets') },
|
||||
{ path: '/sub-agents', label: t('nav.subAgents') },
|
||||
]);
|
||||
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
|
||||
@@ -55,6 +53,13 @@ const currentLabel = computed(() => {
|
||||
|
||||
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');
|
||||
return t('role.agent');
|
||||
});
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(auth.user?.username ?? '').charAt(0).toUpperCase()
|
||||
);
|
||||
@@ -171,7 +176,7 @@ watch(() => route.path, () => {
|
||||
<div class="avatar">{{ userInitial }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ auth.user?.username }}</span>
|
||||
<span class="user-role">{{ auth.isAdmin ? t('role.admin') : t('role.agent') }}</span>
|
||||
<span class="user-role">{{ roleLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminLocaleSwitcher />
|
||||
|
||||
@@ -5,5 +5,14 @@ import 'element-plus/dist/index.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import i18n from './i18n';
|
||||
import { ensureStaffSession } from './utils/session-hydrate';
|
||||
|
||||
createApp(App).use(i18n).use(router).use(ElementPlus).mount('#app');
|
||||
async function bootstrap() {
|
||||
if (localStorage.getItem('manage_token')) {
|
||||
await ensureStaffSession();
|
||||
}
|
||||
|
||||
createApp(App).use(i18n).use(router).use(ElementPlus).mount('#app');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { ensureStaffSession } from '../utils/session-hydrate';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -13,13 +14,12 @@ const router = createRouter({
|
||||
{ path: '', component: () => import('../views/HomeEntry.vue') },
|
||||
{
|
||||
path: 'users',
|
||||
component: () => import('../views/Users.vue'),
|
||||
component: () => import('../views/AgentManager.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
component: () => import('../views/Agents.vue'),
|
||||
meta: { adminOnly: true },
|
||||
redirect: '/users',
|
||||
},
|
||||
{
|
||||
path: 'matches',
|
||||
@@ -83,8 +83,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: 'sub-agents',
|
||||
component: () => import('../views/agent/SubAgents.vue'),
|
||||
meta: { agentOnly: true },
|
||||
redirect: '/my-players',
|
||||
},
|
||||
{
|
||||
path: 'my-bets',
|
||||
@@ -96,9 +95,14 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
const hasToken = !!auth.token.value;
|
||||
|
||||
if (hasToken) {
|
||||
await ensureStaffSession();
|
||||
}
|
||||
|
||||
const hasUser = !!auth.user.value?.userType;
|
||||
|
||||
if (to.meta.public) {
|
||||
@@ -119,6 +123,10 @@ router.beforeEach((to) => {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (to.meta.tier1AgentOnly && !auth.isTier1Agent.value) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,15 +8,43 @@ export interface StaffUser {
|
||||
userType: StaffUserType;
|
||||
locale?: string;
|
||||
role?: string;
|
||||
agentLevel?: number | null;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'manage_token';
|
||||
const USER_KEY = 'manage_user';
|
||||
|
||||
function decodeJwtStaffClaims(rawToken: string): Partial<StaffUser> | null {
|
||||
try {
|
||||
const segment = rawToken.split('.')[1];
|
||||
if (!segment) return null;
|
||||
const padded = segment.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(padded)) as {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
userType?: string;
|
||||
role?: string;
|
||||
};
|
||||
if (payload.userType !== 'ADMIN' && payload.userType !== 'AGENT') return null;
|
||||
if (!payload.sub || !payload.username) return null;
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
userType: payload.userType as StaffUserType,
|
||||
role: payload.role,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadUser(): StaffUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as StaffUser) : null;
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<StaffUser>;
|
||||
if (!parsed.id || !parsed.username || !parsed.userType) return null;
|
||||
return parsed as StaffUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -28,14 +56,14 @@ function migrateLegacyTokens() {
|
||||
const legacyAgent = localStorage.getItem('agent_token');
|
||||
if (legacyAdmin) {
|
||||
localStorage.setItem(TOKEN_KEY, legacyAdmin);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ userType: 'ADMIN' }));
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem(USER_KEY);
|
||||
return;
|
||||
}
|
||||
if (legacyAgent) {
|
||||
localStorage.setItem(TOKEN_KEY, legacyAgent);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ userType: 'AGENT' }));
|
||||
localStorage.removeItem('agent_token');
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +72,42 @@ migrateLegacyTokens();
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '');
|
||||
const user = ref<StaffUser | null>(loadUser());
|
||||
|
||||
/** Align manage_user.userType with JWT when localStorage is stale (common after account switch). */
|
||||
export function reconcileStaffSessionFromToken(): boolean {
|
||||
if (!token.value) return false;
|
||||
const claims = decodeJwtStaffClaims(token.value);
|
||||
if (!claims?.userType || !claims.id || !claims.username) return false;
|
||||
|
||||
if (
|
||||
user.value?.id === claims.id &&
|
||||
user.value.username === claims.username &&
|
||||
user.value.userType === claims.userType
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const next: StaffUser = {
|
||||
id: claims.id,
|
||||
username: claims.username,
|
||||
userType: claims.userType,
|
||||
locale: user.value?.locale,
|
||||
role: claims.role ?? user.value?.role,
|
||||
};
|
||||
user.value = next;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(next));
|
||||
return true;
|
||||
}
|
||||
|
||||
reconcileStaffSessionFromToken();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', () => {
|
||||
token.value = localStorage.getItem(TOKEN_KEY) || '';
|
||||
user.value = loadUser();
|
||||
reconcileStaffSessionFromToken();
|
||||
});
|
||||
}
|
||||
|
||||
export function clearStaffSession() {
|
||||
token.value = '';
|
||||
user.value = null;
|
||||
@@ -51,11 +115,18 @@ export function clearStaffSession() {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('agent_token');
|
||||
void import('../utils/session-hydrate').then((m) => m.resetStaffSessionHydration());
|
||||
}
|
||||
|
||||
function resolveUserType(): StaffUserType | null {
|
||||
return user.value?.userType ?? decodeJwtStaffClaims(token.value)?.userType ?? null;
|
||||
}
|
||||
|
||||
export function useAuthStore() {
|
||||
const isAdmin = computed(() => user.value?.userType === 'ADMIN');
|
||||
const isAgent = computed(() => user.value?.userType === 'AGENT');
|
||||
const isAdmin = computed(() => resolveUserType() === 'ADMIN');
|
||||
const isAgent = computed(() => resolveUserType() === 'AGENT');
|
||||
const isTier1Agent = computed(() => isAgent.value && user.value?.agentLevel === 1);
|
||||
const isTier2Agent = computed(() => isAgent.value && user.value?.agentLevel === 2);
|
||||
const portalLabel = computed(() => (isAdmin.value ? '平台后台' : '代理后台'));
|
||||
|
||||
function setSession(newToken: string, newUser: StaffUser) {
|
||||
@@ -76,9 +147,12 @@ export function useAuthStore() {
|
||||
user,
|
||||
isAdmin,
|
||||
isAgent,
|
||||
isTier1Agent,
|
||||
isTier2Agent,
|
||||
portalLabel,
|
||||
setSession,
|
||||
logout,
|
||||
clearStaffSession,
|
||||
reconcileStaffSessionFromToken,
|
||||
};
|
||||
}
|
||||
|
||||
71
apps/admin/src/utils/agent-credit-context.ts
Normal file
71
apps/admin/src/utils/agent-credit-context.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AgentDetail, AgentRow } from '../views/agent-form';
|
||||
import type { AgentSubAgentRow } from '../views/agent/agent-sub-agent-form';
|
||||
import api from '../api';
|
||||
|
||||
export type AgentCreditSnapshot = {
|
||||
username: string;
|
||||
level?: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
};
|
||||
|
||||
export type AgentCreditAdjustContext = {
|
||||
target: AgentCreditSnapshot;
|
||||
parent?: AgentCreditSnapshot;
|
||||
};
|
||||
|
||||
function dec(value: string | number | null | undefined): number {
|
||||
const n = Number(value ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function snapshotFromAgentRow(
|
||||
row: Pick<
|
||||
AgentRow | AgentDetail | AgentSubAgentRow,
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level'
|
||||
> & {
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
},
|
||||
): AgentCreditSnapshot {
|
||||
return {
|
||||
username: row.username,
|
||||
level: row.level,
|
||||
creditLimit: String(row.creditLimit),
|
||||
usedCredit: String(row.usedCredit),
|
||||
availableCredit: String(row.availableCredit),
|
||||
directPlayerLiability: row.directPlayerLiability,
|
||||
childAgentExposure: row.childAgentExposure,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAdminAgentCreditContext(userId: string): Promise<AgentCreditAdjustContext> {
|
||||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||||
const detail = data.data as AgentDetail;
|
||||
const target = snapshotFromAgentRow(detail);
|
||||
if (!detail.parentAgentId) {
|
||||
return { target };
|
||||
}
|
||||
const { data: parentRes } = await api.get(`/admin/agents/${detail.parentAgentId}`);
|
||||
const parentDetail = parentRes.data as AgentDetail;
|
||||
return {
|
||||
target,
|
||||
parent: snapshotFromAgentRow(parentDetail),
|
||||
};
|
||||
}
|
||||
|
||||
/** 下级代理增信时,正数调整量上限约等于上级可用授信(与后端 exposure 校验一致) */
|
||||
export function maxCreditIncreaseAmount(ctx: AgentCreditAdjustContext | null): number | undefined {
|
||||
if (!ctx?.parent) return undefined;
|
||||
return Math.max(0, dec(ctx.parent.availableCredit));
|
||||
}
|
||||
|
||||
export function projectedCreditLimit(ctx: AgentCreditAdjustContext | null, adjustAmount: number): string | null {
|
||||
if (!ctx || !Number.isFinite(adjustAmount)) return null;
|
||||
const after = dec(ctx.target.creditLimit) + adjustAmount;
|
||||
if (after < 0) return null;
|
||||
return String(after);
|
||||
}
|
||||
17
apps/admin/src/utils/expandable-table.ts
Normal file
17
apps/admin/src/utils/expandable-table.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Skip row-click expand when the user interacts with controls inside the row. */
|
||||
export function isExpandRowInteractiveClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return !!target.closest(
|
||||
'.action-btns, .el-button, .el-link, a, input, textarea, .el-input, .el-select, .el-checkbox, .el-switch, .el-dropdown',
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldToggleExpandOnRowClick(event: MouseEvent): boolean {
|
||||
const el = event.target as HTMLElement;
|
||||
if (el.closest('.el-table__expand-icon')) return false;
|
||||
return !isExpandRowInteractiveClick(event.target);
|
||||
}
|
||||
|
||||
export function expandableTableRowClassName(): string {
|
||||
return 'row-expandable';
|
||||
}
|
||||
79
apps/admin/src/utils/session-hydrate.ts
Normal file
79
apps/admin/src/utils/session-hydrate.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
reconcileStaffSessionFromToken,
|
||||
useAuthStore,
|
||||
type StaffUser,
|
||||
type StaffUserType,
|
||||
} from '../stores/auth';
|
||||
|
||||
let hydratePromise: Promise<boolean> | null = null;
|
||||
|
||||
function isStaffUserType(value: unknown): value is StaffUserType {
|
||||
return value === 'ADMIN' || value === 'AGENT';
|
||||
}
|
||||
|
||||
export function resetStaffSessionHydration() {
|
||||
hydratePromise = null;
|
||||
}
|
||||
|
||||
function hasCompleteStaffUser(u: StaffUser | null | undefined): u is StaffUser {
|
||||
return !!(u?.id && u.username && u.userType);
|
||||
}
|
||||
|
||||
/** Sync manage_user from JWT + /manage/auth/me (fixes stale localStorage userType). */
|
||||
export async function hydrateStaffSession(): Promise<boolean> {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token.value) return false;
|
||||
if (hydratePromise) return hydratePromise;
|
||||
|
||||
hydratePromise = (async () => {
|
||||
reconcileStaffSessionFromToken();
|
||||
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
auth.clearStaffSession();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { default: api } = await import('../api');
|
||||
const { data } = await api.get('/manage/auth/me');
|
||||
const raw = data.data as Partial<StaffUser>;
|
||||
if (!raw?.id || !raw.username || !isStaffUserType(raw.userType)) {
|
||||
return true;
|
||||
}
|
||||
auth.setSession(auth.token.value, {
|
||||
id: raw.id,
|
||||
username: raw.username,
|
||||
userType: raw.userType,
|
||||
locale: raw.locale,
|
||||
role: raw.role,
|
||||
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
|
||||
});
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 401) {
|
||||
auth.clearStaffSession();
|
||||
return false;
|
||||
}
|
||||
return hasCompleteStaffUser(auth.user.value);
|
||||
} finally {
|
||||
hydratePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return hydratePromise;
|
||||
}
|
||||
|
||||
/** Run before any authenticated route — JWT reconcile + optional /me refresh. */
|
||||
export async function ensureStaffSession(): Promise<boolean> {
|
||||
reconcileStaffSessionFromToken();
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token.value) return false;
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
reconcileStaffSessionFromToken();
|
||||
}
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
return false;
|
||||
}
|
||||
return hydrateStaffSession();
|
||||
}
|
||||
51
apps/admin/src/utils/wallet-transfer-context.ts
Normal file
51
apps/admin/src/utils/wallet-transfer-context.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface WalletTransferCreditContext {
|
||||
agentId: string;
|
||||
agentUsername: string;
|
||||
agentLevel: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
maxSingleDeposit: string | null;
|
||||
maxDailyDeposit: string | null;
|
||||
dailyDepositUsed: string | null;
|
||||
appliesDepositLimits: boolean;
|
||||
}
|
||||
|
||||
export interface WalletTransferContext {
|
||||
player: {
|
||||
id: string;
|
||||
username: string;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
};
|
||||
credit: WalletTransferCreditContext | null;
|
||||
}
|
||||
|
||||
export function parseCreditAvailable(ctx: WalletTransferContext | null): number {
|
||||
const n = Number(ctx?.credit?.availableCredit ?? NaN);
|
||||
return Number.isFinite(n) ? Math.max(0, n) : Infinity;
|
||||
}
|
||||
|
||||
export function parsePlayerAvailable(ctx: WalletTransferContext | null): number {
|
||||
const n = Number(ctx?.player?.availableBalance ?? NaN);
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
/** 上分金额上限:可用授信;代理端再叠加单笔/日限 */
|
||||
export function depositAmountCap(ctx: WalletTransferContext | null): number | undefined {
|
||||
if (!ctx?.credit) return undefined;
|
||||
const available = parseCreditAvailable(ctx);
|
||||
if (!Number.isFinite(available)) return undefined;
|
||||
let cap = available;
|
||||
if (ctx.credit.appliesDepositLimits) {
|
||||
const single = ctx.credit.maxSingleDeposit ? Number(ctx.credit.maxSingleDeposit) : Infinity;
|
||||
if (Number.isFinite(single)) cap = Math.min(cap, single);
|
||||
if (ctx.credit.maxDailyDeposit) {
|
||||
const dailyRem =
|
||||
Number(ctx.credit.maxDailyDeposit) - Number(ctx.credit.dailyDepositUsed ?? 0);
|
||||
if (Number.isFinite(dailyRem)) cap = Math.min(cap, Math.max(0, dailyRem));
|
||||
}
|
||||
}
|
||||
if (cap <= 0) return 0;
|
||||
return cap;
|
||||
}
|
||||
1644
apps/admin/src/views/AgentManager.vue
Normal file
1644
apps/admin/src/views/AgentManager.vue
Normal file
@@ -0,0 +1,1644 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import { clearStaffSession } from '../stores/auth';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
|
||||
import {
|
||||
emptyPlayerCreateForm,
|
||||
emptyPlayerEditForm,
|
||||
editFormFromDetail,
|
||||
buildCreatePlayerPayload,
|
||||
type PlayerRow,
|
||||
type PlayerDetail,
|
||||
type PlayerCreateForm,
|
||||
type PlayerEditForm,
|
||||
} from './user-form.ts';
|
||||
import {
|
||||
emptyAgentEditForm,
|
||||
editFormFromAgentDetail,
|
||||
type AgentRow,
|
||||
type AgentDetail,
|
||||
type AgentEditForm,
|
||||
} from './agent-form.ts';
|
||||
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
||||
import {
|
||||
formatAmount,
|
||||
formatAmountFull,
|
||||
shouldCompactAmount as shouldCompact,
|
||||
} from '../utils/format-amount';
|
||||
import {
|
||||
shouldToggleExpandOnRowClick,
|
||||
expandableTableRowClassName,
|
||||
} from '../utils/expandable-table';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||||
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
||||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||||
import {
|
||||
fetchAdminAgentCreditContext,
|
||||
type AgentCreditAdjustContext,
|
||||
} from '../utils/agent-credit-context';
|
||||
|
||||
/* ─── Main agent list ─── */
|
||||
const agents = ref<AgentRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const keyword = ref('');
|
||||
const filterStatus = ref('');
|
||||
|
||||
/* ─── Expansion state ─── */
|
||||
const expandedSet = ref(new Set<string>());
|
||||
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
||||
const agentSubAgentsMap = ref<Record<string, AgentRow[]>>({});
|
||||
const expandLoading = ref<Record<string, boolean>>({});
|
||||
const innerTabMap = ref<Record<string, string>>({});
|
||||
const agentTableRef = ref();
|
||||
|
||||
/* Sub-agent nested expansion (players under tier-2 agents) */
|
||||
const subAgentExpandedKeys = ref<string[]>([]);
|
||||
const subAgentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
||||
const subAgentExpandLoading = ref<Record<string, boolean>>({});
|
||||
|
||||
/* ─── Dialogs ─── */
|
||||
const createVisible = ref(false);
|
||||
const editPlayerVisible = ref(false);
|
||||
const editAgentVisible = ref(false);
|
||||
const detailPlayerVisible = ref(false);
|
||||
const detailAgentVisible = ref(false);
|
||||
const creditVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const editPlayerLoading = ref(false);
|
||||
const editAgentLoading = ref(false);
|
||||
const creditLoading = ref(false);
|
||||
|
||||
/* ─── Create form (unified) ─── */
|
||||
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
|
||||
const createParentAgentId = ref(''); // set when creating from expansion
|
||||
const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent
|
||||
|
||||
/* ─── Edit forms ─── */
|
||||
const editPlayerForm = ref<PlayerEditForm>(emptyPlayerEditForm());
|
||||
const editAgentForm = ref<AgentEditForm>(emptyAgentEditForm());
|
||||
const editingId = ref('');
|
||||
|
||||
/* ─── Detail ─── */
|
||||
const playerDetail = ref<PlayerDetail | null>(null);
|
||||
const agentDetail = ref<AgentDetail | null>(null);
|
||||
|
||||
/* ─── Credit ─── */
|
||||
const creditForm = ref({ amount: 10000, remark: '' });
|
||||
const creditContext = ref<AgentCreditAdjustContext | null>(null);
|
||||
const creditContextLoading = ref(false);
|
||||
|
||||
/* ─── Global settings ─── */
|
||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||
const bettingLimits = ref({
|
||||
minStake: 1,
|
||||
maxStakeSingle: 50000,
|
||||
maxStakeParlay: 20000,
|
||||
maxPayoutSingle: 500000,
|
||||
maxPayoutParlay: 1000000,
|
||||
dailyStakeLimit: 200000,
|
||||
});
|
||||
const settingsSaving = ref(false);
|
||||
const limitsSaving = ref(false);
|
||||
const agentSuspendSettings = ref({
|
||||
suspendFreezeDirectPlayers: false,
|
||||
suspendBlockPlayerLogin: false,
|
||||
});
|
||||
const agentSuspendSaving = ref(false);
|
||||
const resetAllowed = ref(false);
|
||||
const resetLoading = ref(false);
|
||||
const resetConfirmPhrase = ref('');
|
||||
const settingsCollapseOpen = ref<string[]>([]);
|
||||
|
||||
/* ─── Init ─── */
|
||||
onMounted(() => {
|
||||
loadPlayerSettings();
|
||||
loadBettingLimits();
|
||||
loadAgentSuspendSettings();
|
||||
loadResetDatabaseStatus();
|
||||
load();
|
||||
});
|
||||
|
||||
/* ─── Load main agent list ─── */
|
||||
async function load() {
|
||||
const { data } = await api.get('/admin/agents', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
agents.value = data.data.items as AgentRow[];
|
||||
total.value = data.data.total;
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
/* ─── Expansion ─── */
|
||||
function getInnerTab(agentId: string) {
|
||||
return innerTabMap.value[agentId] || 'players';
|
||||
}
|
||||
|
||||
function setInnerTab(agentId: string, tab: string) {
|
||||
innerTabMap.value[agentId] = tab;
|
||||
}
|
||||
|
||||
async function onExpandChange(row: AgentRow, expandedRows: AgentRow[]) {
|
||||
// Track expanded rows
|
||||
expandedSet.value = new Set(expandedRows.map(r => r.userId));
|
||||
// Load data if newly expanded
|
||||
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
|
||||
await loadExpansionData(row.userId);
|
||||
}
|
||||
}
|
||||
|
||||
function onAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
|
||||
if (!shouldToggleExpandOnRowClick(event)) return;
|
||||
agentTableRef.value?.toggleRowExpansion(row);
|
||||
}
|
||||
|
||||
async function loadExpansionData(agentId: string) {
|
||||
expandLoading.value[agentId] = true;
|
||||
try {
|
||||
const [playersRes, subAgentsRes] = await Promise.all([
|
||||
api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }),
|
||||
api.get('/admin/agents', { params: { parentAgentId: agentId, pageSize: 100 } }),
|
||||
]);
|
||||
agentPlayersMap.value[agentId] = playersRes.data.data.items;
|
||||
agentSubAgentsMap.value[agentId] = subAgentsRes.data.data.items;
|
||||
} catch {
|
||||
agentPlayersMap.value[agentId] = [];
|
||||
agentSubAgentsMap.value[agentId] = [];
|
||||
} finally {
|
||||
expandLoading.value[agentId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayers(agentId: string) {
|
||||
return agentPlayersMap.value[agentId] || [];
|
||||
}
|
||||
|
||||
function getSubAgents(agentId: string) {
|
||||
return agentSubAgentsMap.value[agentId] || [];
|
||||
}
|
||||
|
||||
async function loadSubAgentPlayers(subAgentUserId: string) {
|
||||
subAgentExpandLoading.value[subAgentUserId] = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/users', {
|
||||
params: { parentId: subAgentUserId, pageSize: 100 },
|
||||
});
|
||||
subAgentPlayersMap.value[subAgentUserId] = data.data.items as PlayerRow[];
|
||||
} catch {
|
||||
subAgentPlayersMap.value[subAgentUserId] = [];
|
||||
} finally {
|
||||
subAgentExpandLoading.value[subAgentUserId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSubAgentPlayers(subAgentUserId: string) {
|
||||
return subAgentPlayersMap.value[subAgentUserId] || [];
|
||||
}
|
||||
|
||||
function onSubAgentExpand(_parentAgentId: string, row: AgentRow, expandedRows: AgentRow[]) {
|
||||
subAgentExpandedKeys.value = expandedRows.map((r) => r.userId);
|
||||
if (!subAgentPlayersMap.value[row.userId]) {
|
||||
loadSubAgentPlayers(row.userId);
|
||||
}
|
||||
}
|
||||
|
||||
function onSubAgentRowClick(_parentAgentId: string, row: AgentRow, _column: unknown, event: MouseEvent) {
|
||||
if (!shouldToggleExpandOnRowClick(event)) return;
|
||||
const id = row.userId;
|
||||
if (subAgentExpandedKeys.value.includes(id)) {
|
||||
subAgentExpandedKeys.value = subAgentExpandedKeys.value.filter((k) => k !== id);
|
||||
} else {
|
||||
subAgentExpandedKeys.value = [...subAgentExpandedKeys.value, id];
|
||||
if (!subAgentPlayersMap.value[id]) {
|
||||
loadSubAgentPlayers(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshExpandedSubAgentPlayers() {
|
||||
for (const subId of subAgentExpandedKeys.value) {
|
||||
loadSubAgentPlayers(subId);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Global settings ─── */
|
||||
async function loadResetDatabaseStatus() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/system/reset-database');
|
||||
resetAllowed.value = !!data.data?.allowed;
|
||||
} catch {
|
||||
resetAllowed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
if (resetConfirmPhrase.value !== 'RESET') {
|
||||
ElMessage.warning(t('user.reset_database_confirm_label'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('user.reset_database_btn'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
resetLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
|
||||
const accounts: string[] = data.data?.demoAccounts ?? [];
|
||||
ElMessage.success({
|
||||
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
|
||||
duration: 8000,
|
||||
});
|
||||
clearStaffSession();
|
||||
await router.push('/login');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
resetLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBettingLimits() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings/betting-limits');
|
||||
bettingLimits.value = data.data;
|
||||
} catch {
|
||||
/* defaults */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBettingLimits() {
|
||||
limitsSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
|
||||
bettingLimits.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadBettingLimits();
|
||||
} finally {
|
||||
limitsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerSettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/users/settings/account');
|
||||
playerSettings.value = data.data;
|
||||
} catch {
|
||||
/* defaults */
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlayerSettings() {
|
||||
settingsSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
|
||||
playerSettings.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadPlayerSettings();
|
||||
} finally {
|
||||
settingsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentSuspendSettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/agents/settings/suspend');
|
||||
agentSuspendSettings.value = data.data;
|
||||
} catch {
|
||||
/* defaults */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgentSuspendSettings() {
|
||||
agentSuspendSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
|
||||
agentSuspendSettings.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadAgentSuspendSettings();
|
||||
} finally {
|
||||
agentSuspendSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Create (unified) ─── */
|
||||
function openCreateGlobal() {
|
||||
createForm.value = emptyPlayerCreateForm();
|
||||
createParentAgentId.value = '';
|
||||
createAccountMode.value = 0;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function openCreatePlayer(parentAgentUserId: string) {
|
||||
createForm.value = emptyPlayerCreateForm();
|
||||
createForm.value.asTier1Agent = false;
|
||||
createForm.value.parentId = parentAgentUserId;
|
||||
createParentAgentId.value = parentAgentUserId;
|
||||
createAccountMode.value = 0;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function openCreateSubAgent(parentAgentUserId: string) {
|
||||
createForm.value = emptyPlayerCreateForm();
|
||||
createForm.value.asTier1Agent = false;
|
||||
createParentAgentId.value = parentAgentUserId;
|
||||
createAccountMode.value = 2;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function onAccountModeChange(mode: number) {
|
||||
createAccountMode.value = mode;
|
||||
if (mode === 0) {
|
||||
// Player
|
||||
createForm.value.asTier1Agent = false;
|
||||
if (createParentAgentId.value) {
|
||||
createForm.value.parentId = createParentAgentId.value;
|
||||
}
|
||||
} else if (mode === 1) {
|
||||
// Tier-1 agent
|
||||
createForm.value.asTier1Agent = true;
|
||||
createForm.value.parentId = '';
|
||||
} else if (mode === 2) {
|
||||
// Sub-agent
|
||||
createForm.value.asTier1Agent = false;
|
||||
createForm.value.parentId = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const isSubAgent = createAccountMode.value === 2;
|
||||
const isAgent = createForm.value.asTier1Agent || isSubAgent;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
if (isSubAgent) {
|
||||
// Sub-agent creation
|
||||
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
|
||||
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
|
||||
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
||||
payload = {
|
||||
username: createForm.value.username.trim(),
|
||||
password: createForm.value.password,
|
||||
asSubAgent: true,
|
||||
parentAgentId: createParentAgentId.value,
|
||||
phone: createForm.value.phone.trim() || undefined,
|
||||
email: createForm.value.email.trim() || undefined,
|
||||
creditLimit: createForm.value.creditLimit,
|
||||
cashbackRate: createForm.value.cashbackRate,
|
||||
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
|
||||
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
|
||||
};
|
||||
} else {
|
||||
payload = buildCreatePlayerPayload(createForm.value);
|
||||
// When creating player from expansion, ensure parentId is set
|
||||
if (createParentAgentId.value && !payload.parentId) {
|
||||
payload.parentId = createParentAgentId.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/users', payload);
|
||||
ElMessage.success(
|
||||
isAgent
|
||||
? t('msg.agent_created')
|
||||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||||
);
|
||||
createVisible.value = false;
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
const parentId = createParentAgentId.value || createForm.value.parentId;
|
||||
if (parentId) {
|
||||
await loadSubAgentPlayers(parentId);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Edit Player ─── */
|
||||
async function openEditPlayer(id: string) {
|
||||
const { data } = await api.get(`/admin/users/${id}`);
|
||||
const d = data.data as PlayerDetail;
|
||||
editingId.value = id;
|
||||
editPlayerForm.value = editFormFromDetail(d);
|
||||
editPlayerVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitEditPlayer() {
|
||||
if (editPlayerForm.value.newPassword && editPlayerForm.value.newPassword.length < 8) {
|
||||
ElMessage.warning(t('err.password_min'));
|
||||
return;
|
||||
}
|
||||
editPlayerLoading.value = true;
|
||||
try {
|
||||
const newPwd = editPlayerForm.value.newPassword.trim();
|
||||
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
||||
username: editPlayerForm.value.username.trim(),
|
||||
parentId: editPlayerForm.value.parentId || '',
|
||||
phone: editPlayerForm.value.phone.trim() || undefined,
|
||||
email: editPlayerForm.value.email.trim() || undefined,
|
||||
password: newPwd || undefined,
|
||||
});
|
||||
const updated = data.data as PlayerDetail;
|
||||
if (newPwd) {
|
||||
editPlayerForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||||
editPlayerForm.value.newPassword = '';
|
||||
ElMessage.success(t('user.msg.password_saved', { password: editPlayerForm.value.managedPassword }));
|
||||
return;
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editPlayerVisible.value = false;
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editPlayerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Edit Agent ─── */
|
||||
async function openEditAgent(userId: string) {
|
||||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||||
const d = data.data as AgentDetail;
|
||||
editingId.value = userId;
|
||||
editAgentForm.value = editFormFromAgentDetail(d);
|
||||
editAgentVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitEditAgent() {
|
||||
if (editAgentForm.value.newPassword && editAgentForm.value.newPassword.length < 8) {
|
||||
ElMessage.warning(t('err.password_min'));
|
||||
return;
|
||||
}
|
||||
editAgentLoading.value = true;
|
||||
try {
|
||||
const newPwd = editAgentForm.value.newPassword.trim();
|
||||
const { data } = await api.put(`/admin/agents/${editingId.value}`, {
|
||||
username: editAgentForm.value.username.trim(),
|
||||
status: editAgentForm.value.status,
|
||||
phone: editAgentForm.value.phone.trim() || undefined,
|
||||
email: editAgentForm.value.email.trim() || undefined,
|
||||
cashbackRate: editAgentForm.value.cashbackRate,
|
||||
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
|
||||
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
|
||||
password: newPwd || undefined,
|
||||
});
|
||||
const updated = data.data as AgentDetail;
|
||||
if (newPwd) {
|
||||
editAgentForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||||
editAgentForm.value.newPassword = '';
|
||||
ElMessage.success(t('user.msg.password_saved', { password: editAgentForm.value.managedPassword }));
|
||||
return;
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editAgentVisible.value = false;
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editAgentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Detail ─── */
|
||||
async function openDetailPlayer(id: string) {
|
||||
const { data } = await api.get(`/admin/users/${id}`);
|
||||
playerDetail.value = data.data as PlayerDetail;
|
||||
detailPlayerVisible.value = true;
|
||||
}
|
||||
|
||||
async function openDetailAgent(userId: string) {
|
||||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||||
agentDetail.value = data.data as AgentDetail;
|
||||
detailAgentVisible.value = true;
|
||||
}
|
||||
|
||||
/* ─── Credit ─── */
|
||||
async function openCredit(userId: string) {
|
||||
editingId.value = userId;
|
||||
creditForm.value = { amount: 10000, remark: '' };
|
||||
creditContext.value = null;
|
||||
creditVisible.value = true;
|
||||
creditContextLoading.value = true;
|
||||
try {
|
||||
creditContext.value = await fetchAdminAgentCreditContext(userId);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
creditVisible.value = false;
|
||||
} finally {
|
||||
creditContextLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCredit() {
|
||||
if (creditForm.value.amount === 0) {
|
||||
ElMessage.warning(t('msg.credit_zero'));
|
||||
return;
|
||||
}
|
||||
creditLoading.value = true;
|
||||
try {
|
||||
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
||||
amount: creditForm.value.amount,
|
||||
requestId: `credit-${editingId.value}-${Date.now()}`,
|
||||
remark: creditForm.value.remark || undefined,
|
||||
});
|
||||
ElMessage.success(t('msg.credit_adjusted'));
|
||||
creditVisible.value = false;
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
|
||||
} finally {
|
||||
creditLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Freeze / Unfreeze ─── */
|
||||
async function toggleFreezePlayer(row: PlayerRow) {
|
||||
const freeze = row.status === 'ACTIVE';
|
||||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('msg.freeze_confirm_body', {
|
||||
action,
|
||||
name: row.username,
|
||||
extra: freeze ? t('msg.freeze_extra') : '',
|
||||
}),
|
||||
t('msg.freeze_confirm_title', { action }),
|
||||
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/admin/users/${row.id}`, {
|
||||
status: freeze ? 'SUSPENDED' : 'ACTIVE',
|
||||
});
|
||||
ElMessage.success(t('msg.freeze_done', { action }));
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Freeze / Unfreeze Agent ─── */
|
||||
async function toggleFreezeAgent(row: AgentRow) {
|
||||
const accountStatus = subAgentAccountStatus(row);
|
||||
const freeze = accountStatus === 'ACTIVE';
|
||||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
||||
|
||||
if (!freeze) {
|
||||
// Unfreeze: simple confirm
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('agent.freeze.confirm_unfreeze_body', { name: row.username }),
|
||||
t('agent.freeze.confirm_freeze_title'),
|
||||
{ type: 'info', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/admin/agents/${row.userId}`, { status: 'ACTIVE' });
|
||||
ElMessage.success(t('agent.msg.freeze_done', { action }));
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Freeze: offer cascade option via a custom dialog
|
||||
let freezeDirectPlayers = false;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('agent.freeze.confirm_freeze_body', { name: row.username }),
|
||||
t('agent.freeze.confirm_freeze_title'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: action,
|
||||
cancelButtonText: t('common.cancel'),
|
||||
distinguishCancelAndClose: true,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// After confirming freeze, ask about cascade (only when enabled in settings)
|
||||
if (row.directPlayerCount > 0 && agentSuspendSettings.value.suspendFreezeDirectPlayers) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('agent.freeze.cascade_hint'),
|
||||
t('agent.freeze.cascade_label'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.yes') || '是',
|
||||
cancelButtonText: t('common.no') || '否',
|
||||
distinguishCancelAndClose: true,
|
||||
},
|
||||
);
|
||||
freezeDirectPlayers = true;
|
||||
} catch {
|
||||
freezeDirectPlayers = false;
|
||||
}
|
||||
} else if (row.directPlayerCount > 0 && !agentSuspendSettings.value.suspendFreezeDirectPlayers) {
|
||||
ElMessage.info(t('agent.suspend.cascade_disabled_hint'));
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/admin/agents/${row.userId}`, {
|
||||
status: 'SUSPENDED',
|
||||
freezeDirectPlayers,
|
||||
});
|
||||
ElMessage.success(
|
||||
freezeDirectPlayers
|
||||
? t('agent.msg.cascade_freeze_done')
|
||||
: t('agent.msg.freeze_done', { action }),
|
||||
);
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
function refreshExpandedParents() {
|
||||
for (const agentId of expandedSet.value) {
|
||||
loadExpansionData(agentId);
|
||||
}
|
||||
refreshExpandedSubAgentPlayers();
|
||||
}
|
||||
|
||||
const {
|
||||
transferVisible,
|
||||
transferLoading,
|
||||
transferType,
|
||||
transferTarget,
|
||||
transferAmount,
|
||||
transferRemark,
|
||||
transferContext,
|
||||
transferContextLoading,
|
||||
transferAmountRange,
|
||||
transferAmountDisabled,
|
||||
transferTitle,
|
||||
openTransfer,
|
||||
submitTransfer,
|
||||
} = useAdminPlayerTransfer(async () => {
|
||||
load();
|
||||
refreshExpandedParents();
|
||||
});
|
||||
|
||||
function creditLine(row: AgentRow) {
|
||||
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
|
||||
}
|
||||
|
||||
function creditLineFull(row: AgentRow) {
|
||||
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString(localeTag.value);
|
||||
}
|
||||
|
||||
function formatLastLogin(v: string | null) {
|
||||
if (!v) return t('common.never_login');
|
||||
const d = new Date(v);
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function statusTagType(s: string) {
|
||||
return s === 'ACTIVE' ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const key = `user.status.${s}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : s;
|
||||
}
|
||||
|
||||
function creditTypeLabel(type: string) {
|
||||
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
|
||||
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
|
||||
return type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page agent-mgr-page">
|
||||
<!-- ─── Global settings collapse ─── -->
|
||||
<el-collapse v-model="settingsCollapseOpen" class="list-settings">
|
||||
<el-collapse-item :title="t('user.page_settings')" name="settings">
|
||||
<div class="list-settings-block">
|
||||
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
|
||||
<el-form inline size="small" class="settings-form">
|
||||
<el-form-item :label="t('user.field.allow_password_change')">
|
||||
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.allow_username_change')">
|
||||
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-settings-block">
|
||||
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
|
||||
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
|
||||
<el-form inline size="small" class="settings-form">
|
||||
<el-form-item :label="t('agent.suspend.freeze_direct_players')">
|
||||
<el-switch
|
||||
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
|
||||
:loading="agentSuspendSaving"
|
||||
@change="saveAgentSuspendSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.suspend.block_player_login')">
|
||||
<el-switch
|
||||
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
|
||||
:loading="agentSuspendSaving"
|
||||
@change="saveAgentSuspendSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-settings-block">
|
||||
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
|
||||
<el-form inline size="small" class="settings-form limits-form">
|
||||
<el-form-item :label="t('user.limit.min_stake')">
|
||||
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_stake_single')">
|
||||
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_stake_parlay')">
|
||||
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_payout_single')">
|
||||
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_payout_parlay')">
|
||||
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.daily_stake')">
|
||||
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">{{ t('common.save') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-settings-block list-settings-block--danger">
|
||||
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
|
||||
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
|
||||
<el-alert v-if="!resetAllowed" type="warning" :closable="false" show-icon class="reset-db-alert" :title="t('user.reset_database_disabled_prod')" />
|
||||
<el-form inline size="small" class="settings-form reset-db-form">
|
||||
<el-form-item :label="t('user.reset_database_confirm_label')">
|
||||
<el-input v-model="resetConfirmPhrase" :placeholder="t('user.reset_database_confirm_ph')" style="width: 160px" :disabled="!resetAllowed" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="danger" plain :loading="resetLoading" :disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'" @click="resetDatabase">{{ t('user.reset_database_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<!-- ─── Filter bar ─── -->
|
||||
<div class="list-chrome">
|
||||
<div class="list-chrome__row">
|
||||
<el-form inline class="list-chrome__grow">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input v-model="keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="list-chrome__actions">
|
||||
<el-button type="primary" @click="openCreateGlobal">{{ t('agent.create_btn') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Agent table ─── -->
|
||||
<section class="list-panel">
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
ref="agentTableRef"
|
||||
:data="agents"
|
||||
stripe
|
||||
row-key="userId"
|
||||
:row-class-name="expandableTableRowClassName"
|
||||
class="expandable-table"
|
||||
@expand-change="onExpandChange"
|
||||
@row-click="onAgentRowClick"
|
||||
>
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
|
||||
<!-- Built-in expand column -->
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="expand-panel">
|
||||
<div v-if="expandLoading[row.userId]" class="expand-loading">
|
||||
{{ t('common.loading') || '加载中...' }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<el-tabs :model-value="getInnerTab(row.userId)" @update:model-value="setInnerTab(row.userId, $event)" class="inner-tabs">
|
||||
<!-- ── Players tab ── -->
|
||||
<el-tab-pane :label="`${t('nav.players')} (${getPlayers(row.userId).length})`" name="players">
|
||||
<div class="inner-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">
|
||||
+ {{ t('user.create_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="getPlayers(row.userId)" stripe size="small" class="inner-table">
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row: player }">
|
||||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||
<template #default="{ row: player }">
|
||||
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
|
||||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
|
||||
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
|
||||
<template #default="{ row: player }">
|
||||
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.last_login')" width="100">
|
||||
<template #default="{ row: player }">
|
||||
<el-tooltip v-if="player.lastLoginAt" :content="formatTime(player.lastLoginAt)" placement="top">
|
||||
<span>{{ formatLastLogin(player.lastLoginAt) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||
<template #default="{ row: player }">
|
||||
<div class="action-btns">
|
||||
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
|
||||
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
|
||||
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── Sub-agents tab ── -->
|
||||
<el-tab-pane :label="`${t('nav.subAgents')} (${getSubAgents(row.userId).length})`" name="subAgents">
|
||||
<div class="inner-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreateSubAgent(row.userId)">
|
||||
+ {{ t('agent.create_sub') || '创建二级代理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="getSubAgents(row.userId)"
|
||||
stripe
|
||||
size="small"
|
||||
row-key="userId"
|
||||
:expand-row-keys="subAgentExpandedKeys"
|
||||
:row-class-name="expandableTableRowClassName"
|
||||
class="inner-table expandable-table"
|
||||
@expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)"
|
||||
@row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)"
|
||||
>
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row: sub }">
|
||||
<div class="expand-panel">
|
||||
<div v-if="subAgentExpandLoading[sub.userId]" class="expand-loading">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="expand-section-header">
|
||||
<div class="expand-section-title">
|
||||
{{ t('nav.players') }} ({{ getSubAgentPlayers(sub.userId).length }})
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click.stop="openCreatePlayer(sub.userId)">
|
||||
+ {{ t('agent_portal.create_player_btn').replace(/^\+ /, '') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="getSubAgentPlayers(sub.userId)" stripe size="small" class="inner-table nested-table">
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row: player }">
|
||||
<el-tag :type="statusTagType(player.status)" size="small">
|
||||
{{ statusLabel(player.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||||
<template #default="{ row: player }">
|
||||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||
<template #default="{ row: player }">
|
||||
<div class="action-btns" @click.stop>
|
||||
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
|
||||
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
|
||||
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userId" label="ID" width="60" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row: sub }">
|
||||
<el-tag :type="statusTagType(subAgentAccountStatus(sub))" size="small">
|
||||
{{ statusLabel(subAgentAccountStatus(sub)) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
|
||||
<template #default="{ row: sub }">
|
||||
<el-tooltip :content="creditLineFull(sub)" placement="top">
|
||||
<span class="amount-compact">{{ creditLine(sub) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
||||
<el-table-column :label="t('agent.col.cashback')" width="70" align="right">
|
||||
<template #default="{ row: sub }">{{ sub.cashbackRate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
|
||||
<template #default="{ row: sub }">
|
||||
<div class="action-btns">
|
||||
<el-button size="small" link type="primary" @click="openDetailAgent(sub.userId)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEditAgent(sub.userId)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(sub.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||
<el-button
|
||||
v-if="subAgentAccountStatus(sub) === 'ACTIVE'"
|
||||
size="small"
|
||||
link
|
||||
type="warning"
|
||||
@click="toggleFreezeAgent(sub)"
|
||||
>{{ t('common.freeze') }}</el-button>
|
||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(sub)">{{ t('common.unfreeze') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="userId" label="ID" width="72" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level" :label="t('agent.col.level')" width="60" align="center">
|
||||
<template #default="{ row }">L{{ row.level }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
||||
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="80" align="center" />
|
||||
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns" @click.stop>
|
||||
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ DIALOGS ═══════════ -->
|
||||
|
||||
<!-- ── Create (unified) ── -->
|
||||
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('user.col.username')" required>
|
||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.password')" required>
|
||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.account_type')">
|
||||
<el-radio-group :model-value="createAccountMode" @update:model-value="onAccountModeChange">
|
||||
<el-radio :value="0">{{ t('user.type.player') }}</el-radio>
|
||||
<el-radio :value="1" :disabled="!!createParentAgentId">{{ t('user.type.tier1_agent') }}</el-radio>
|
||||
<el-radio :value="2" :disabled="!createParentAgentId">{{ t('user.type.sub_agent') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="field-hint">
|
||||
<template v-if="createParentAgentId">
|
||||
{{ t('agent.hint.creating_under_agent') }}
|
||||
</template>
|
||||
<template v-else>{{ t('user.hint.account_type') }}</template>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Player fields (when mode is player and no parent agent context) -->
|
||||
<template v-if="createAccountMode === 0 && !createParentAgentId">
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
||||
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
|
||||
</el-select>
|
||||
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Agent fields (tier1 or sub-agent) -->
|
||||
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
||||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||||
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number v-model="createForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.max_single_deposit')">
|
||||
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
||||
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Player-only: initial balance -->
|
||||
<template v-if="createAccountMode === 0">
|
||||
<el-form-item :label="t('user.field.initial_balance')">
|
||||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.deposit_remark')">
|
||||
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Edit Player ── -->
|
||||
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="480px" destroy-on-close class="user-edit-dialog">
|
||||
<el-form label-width="84px" size="small" class="compact-edit-form">
|
||||
<div class="edit-meta">
|
||||
<span>ID {{ editPlayerForm.id }}</span>
|
||||
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
|
||||
</div>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
<div class="password-mgmt-block">
|
||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||
<el-form-item :label="t('user.field.current_password')">
|
||||
<span v-if="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
|
||||
<span v-else class="password-empty">—</span>
|
||||
</el-form-item>
|
||||
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
|
||||
<el-form-item :label="t('user.field.reset_password')">
|
||||
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select v-model="editPlayerForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
||||
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-descriptions :column="2" size="small" border class="edit-stats">
|
||||
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.bets_summary')">{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||||
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
|
||||
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="editPlayerVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" :loading="editPlayerLoading" @click="submitEditPlayer">{{ t('user.btn.save_profile') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Edit Agent ── -->
|
||||
<el-dialog v-model="editAgentVisible" :title="t('agent.dialog.edit')" width="520px" destroy-on-close class="agent-edit-dialog">
|
||||
<el-form label-width="88px" size="small" class="compact-edit-form">
|
||||
<div class="edit-meta">
|
||||
<span>ID {{ editAgentForm.id }}</span>
|
||||
<el-tag :type="statusTagType(editAgentForm.status)" size="small">{{ statusLabel(editAgentForm.status) }}</el-tag>
|
||||
<el-tag size="small" type="info">L{{ editAgentForm.level }}</el-tag>
|
||||
<span v-if="editAgentForm.parentUsername" class="text-muted">{{ t('user.col.agent') }}: {{ editAgentForm.parentUsername }}</span>
|
||||
</div>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="editAgentForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
<div class="password-mgmt-block">
|
||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||
<el-form-item :label="t('user.field.current_password')">
|
||||
<span v-if="editAgentForm.managedPassword" class="password-plain">{{ editAgentForm.managedPassword }}</span>
|
||||
<span v-else class="password-empty">—</span>
|
||||
</el-form-item>
|
||||
<p v-if="!editAgentForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
|
||||
<el-form-item :label="t('user.field.reset_password')">
|
||||
<el-input v-model="editAgentForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-radio-group v-model="editAgentForm.status">
|
||||
<el-radio value="ACTIVE">{{ t('user.status.ACTIVE') }}</el-radio>
|
||||
<el-radio value="SUSPENDED">{{ t('user.status.SUSPENDED') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number v-model="editAgentForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.max_single_deposit')">
|
||||
<el-input-number v-model="editAgentForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
||||
<el-input-number v-model="editAgentForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editAgentForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editAgentForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-descriptions :column="2" size="small" border class="edit-stats">
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ formatAmount(editAgentForm.creditLimit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editAgentForm.availableCredit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">{{ formatAmount(editAgentForm.usedCredit) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editAgentForm.directPlayerCount }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ editAgentForm.childAgentCount }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||||
{{ editAgentForm.lastLoginAt ? formatTime(editAgentForm.lastLoginAt) : t('common.never_login') }}
|
||||
· {{ t('user.login_fail_value', { n: editAgentForm.loginFailCount }) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="editAgentVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" :loading="editAgentLoading" @click="submitEditAgent">{{ t('user.btn.save_profile') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Player transfer (deposit / withdraw) ── -->
|
||||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
|
||||
<WalletTransferContext
|
||||
:context="transferContext"
|
||||
:mode="transferType"
|
||||
:loading="transferContextLoading"
|
||||
/>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item :label="t('common.col_id')">
|
||||
<span>{{ transferTarget?.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number
|
||||
v-model="transferAmount"
|
||||
:min="transferAmountRange.min"
|
||||
:max="transferAmountRange.max"
|
||||
:disabled="transferAmountDisabled"
|
||||
:step="10"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="transferRemark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="transferLoading"
|
||||
:disabled="transferAmountDisabled"
|
||||
@click="submitTransfer"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Credit ── -->
|
||||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="520px" destroy-on-close>
|
||||
<AgentCreditContext
|
||||
:context="creditContext"
|
||||
:loading="creditContextLoading"
|
||||
:adjust-amount="creditForm.amount"
|
||||
/>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item :label="t('agent.field.agent_id')">
|
||||
<el-input :model-value="editingId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.adjust_amount')">
|
||||
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
|
||||
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">{{ t('agent.btn.confirm_adjust') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Player Detail ── -->
|
||||
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
|
||||
<template v-if="playerDetail">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item :label="t('common.col_id')">{{ playerDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ playerDetail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.current_password')">{{ playerDetail.managedPassword ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="!playerDetail.managedPassword" :span="2">
|
||||
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.status')">
|
||||
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.agent')">{{ playerDetail.parentUsername ?? t('common.platform_direct') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.available')">
|
||||
{{ formatAmount(playerDetail.availableBalance) }}
|
||||
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.availableBalance) }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.frozen_balance')">
|
||||
{{ formatAmount(playerDetail.frozenBalance) }}
|
||||
<span v-if="shouldCompact(playerDetail.frozenBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.frozenBalance) }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')">
|
||||
{{ playerDetail.lastLoginAt ? formatTime(playerDetail.lastLoginAt) : t('common.never_login') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ── Agent Detail ── -->
|
||||
<el-dialog v-model="detailAgentVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
|
||||
<template v-if="agentDetail">
|
||||
<el-descriptions :column="2" border size="small" class="detail-block">
|
||||
<el-descriptions-item :label="t('common.col_id')">{{ agentDetail.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ agentDetail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.status')">
|
||||
<el-tag :type="statusTagType(agentDetail.status)" size="small">{{ statusLabel(agentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.level')">L{{ agentDetail.level }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">
|
||||
{{ formatAmount(agentDetail.creditLimit) }}
|
||||
<span v-if="shouldCompact(agentDetail.creditLimit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.creditLimit) }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">
|
||||
{{ formatAmount(agentDetail.usedCredit) }}
|
||||
<span v-if="shouldCompact(agentDetail.usedCredit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.usedCredit) }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||||
{{ formatAmount(agentDetail.availableCredit) }}
|
||||
<span v-if="shouldCompact(agentDetail.availableCredit)" class="amount-full-hint">({{ formatAmountFull(agentDetail.availableCredit) }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ agentDetail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ agentDetail.childAgentCount }} {{ t('common.people') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.player_liability')">{{ formatAmount(agentDetail.directPlayerLiability) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">{{ formatAmount(agentDetail.childAgentExposure) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.cashback')">{{ agentDetail.cashbackRate }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.phone')">{{ agentDetail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.email')">{{ agentDetail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||||
{{ agentDetail.lastLoginAt ? formatTime(agentDetail.lastLoginAt) : t('common.never_login') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">{{ t('agent.section.credit_log') }}</div>
|
||||
<el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')">
|
||||
<el-table-column :label="t('agent.col.credit_type')" width="80">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
<span>{{ formatAmount(row.amount) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
|
||||
<span>{{ formatAmount(row.creditAfter) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Expansion ─── */
|
||||
.expand-panel {
|
||||
padding: 4px 16px 8px;
|
||||
}
|
||||
.expand-loading {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
.expand-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.expand-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
.expandable-table :deep(.row-expandable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.nested-table {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.inner-tabs {
|
||||
margin-top: 0;
|
||||
}
|
||||
.inner-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.inner-tabs :deep(.el-tabs__nav-wrap) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.inner-tabs :deep(.el-tabs__item) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 13px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.inner-tabs :deep(.el-tabs__content) {
|
||||
padding: 0;
|
||||
}
|
||||
.inner-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.inner-table {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.expandable-table :deep(.row-expandable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Inherited from old pages ─── */
|
||||
.settings-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||
.text-muted { color: #666; font-size: 12px; }
|
||||
.password-mgmt-block {
|
||||
margin: 4px 0 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.block-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #e8a84a;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.password-plain {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f0d090;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.password-empty { color: #666; }
|
||||
.block-hint { margin: -4px 0 8px; }
|
||||
.edit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.compact-edit-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.edit-stats { margin-top: 4px; }
|
||||
.list-settings-block--danger {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(245, 108, 108, 0.35);
|
||||
}
|
||||
.list-settings-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reset-db-alert { margin-bottom: 10px; }
|
||||
.detail-block { margin-bottom: 16px; }
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 玩家列表「冻结」:橙黄底白字 */
|
||||
.agent-mgr-page .el-button.is-link.el-button--warning {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
|
||||
border: 1px solid rgba(232, 168, 74, 0.45) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 5px 11px !important;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.agent-mgr-page .el-button.is-link.el-button--warning:hover,
|
||||
.agent-mgr-page .el-button.is-link.el-button--warning:focus {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
|
||||
border-color: rgba(240, 188, 98, 0.55) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
shouldCompactAmount as shouldCompact,
|
||||
} from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
||||
import {
|
||||
fetchAdminAgentCreditContext,
|
||||
type AgentCreditAdjustContext,
|
||||
} from '../utils/agent-credit-context';
|
||||
|
||||
const agents = ref<AgentRow[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -56,6 +61,8 @@ const detail = ref<AgentDetail | null>(null);
|
||||
const editingId = ref('');
|
||||
|
||||
const creditForm = ref({ amount: 10000, remark: '' });
|
||||
const creditContext = ref<AgentCreditAdjustContext | null>(null);
|
||||
const creditContextLoading = ref(false);
|
||||
|
||||
onMounted(load);
|
||||
|
||||
@@ -130,10 +137,21 @@ async function openEdit(userId: string) {
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openCredit(row: AgentRow) {
|
||||
async function openCredit(row: AgentRow) {
|
||||
editingId.value = row.userId;
|
||||
creditForm.value = { amount: 10000, remark: '' };
|
||||
creditContext.value = null;
|
||||
creditVisible.value = true;
|
||||
creditContextLoading.value = true;
|
||||
try {
|
||||
creditContext.value = await fetchAdminAgentCreditContext(row.userId);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
creditVisible.value = false;
|
||||
} finally {
|
||||
creditContextLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
@@ -271,6 +289,7 @@ function creditTypeLabel(type: string) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="96" align="center" />
|
||||
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="96" align="center" />
|
||||
<el-table-column :label="t('agent.col.cashback')" width="88" align="right">
|
||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||
</el-table-column>
|
||||
@@ -389,7 +408,12 @@ function creditTypeLabel(type: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="420px" destroy-on-close>
|
||||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="520px" destroy-on-close>
|
||||
<AgentCreditContext
|
||||
:context="creditContext"
|
||||
:loading="creditContextLoading"
|
||||
:adjust-amount="creditForm.amount"
|
||||
/>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item :label="t('agent.field.agent_id')">
|
||||
<el-input :model-value="editingId" disabled />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import { resolveApiError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
interface CashbackBatch {
|
||||
@@ -104,13 +105,6 @@ function statusTagType(status: string) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string) {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(';');
|
||||
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function tableSummary(param: {
|
||||
columns: TableColumnCtx<CashbackPreviewItem>[];
|
||||
data: CashbackPreviewItem[];
|
||||
@@ -141,6 +135,8 @@ async function loadHistory() {
|
||||
});
|
||||
history.value = (data.data.items ?? []) as CashbackBatch[];
|
||||
historyTotal.value = data.data.total ?? 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
@@ -162,7 +158,7 @@ async function generatePreview() {
|
||||
}
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -186,7 +182,7 @@ async function confirmBatchId(batchId: string) {
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +204,7 @@ async function cancelBatchId(batchId: string) {
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +220,9 @@ async function openDetail(batchId: string) {
|
||||
try {
|
||||
const { data } = await api.get(`/admin/cashbacks/${batchId}`);
|
||||
detail.value = data.data as CashbackPreview;
|
||||
} catch (err) {
|
||||
detailVisible.value = false;
|
||||
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, onBeforeMount, type Component } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import AdminDashboard from './Dashboard.vue';
|
||||
import AgentDashboard from './agent/Dashboard.vue';
|
||||
import { ensureStaffSession } from '../utils/session-hydrate';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const dashboardView = shallowRef<Component | null>(null);
|
||||
const booting = shallowRef(true);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await ensureStaffSession();
|
||||
dashboardView.value = auth.isAdmin.value
|
||||
? (await import('./Dashboard.vue')).default
|
||||
: (await import('./agent/Dashboard.vue')).default;
|
||||
booting.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminDashboard v-if="auth.isAdmin" />
|
||||
<AgentDashboard v-else />
|
||||
<div v-if="booting" v-loading="true" class="home-boot" />
|
||||
<component v-else :is="dashboardView" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-boot {
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,6 +93,10 @@ async function login() {
|
||||
<span class="quick-role">{{ t('login.quick_agent') }}</span>
|
||||
<span class="quick-acc">agent1</span>
|
||||
</button>
|
||||
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('agent2', 'Agent@123')">
|
||||
<span class="quick-role">{{ t('login.quick_agent2') }}</span>
|
||||
<span class="quick-acc">agent2</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
shouldCompactAmount as shouldCompact,
|
||||
} from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||||
|
||||
const users = ref<PlayerRow[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -39,17 +41,30 @@ const agentOptions = ref<{ id: string; username: string }[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const depositVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const editLoading = ref(false);
|
||||
const depositLoading = ref(false);
|
||||
|
||||
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
|
||||
const editForm = ref<PlayerEditForm>(emptyPlayerEditForm());
|
||||
const detail = ref<PlayerDetail | null>(null);
|
||||
const editingId = ref('');
|
||||
|
||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||
const {
|
||||
transferVisible,
|
||||
transferLoading,
|
||||
transferType,
|
||||
transferTarget,
|
||||
transferAmount,
|
||||
transferRemark,
|
||||
transferContext,
|
||||
transferContextLoading,
|
||||
transferAmountRange,
|
||||
transferAmountDisabled,
|
||||
transferTitle,
|
||||
openTransfer,
|
||||
submitTransfer,
|
||||
} = useAdminPlayerTransfer(() => load());
|
||||
|
||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||
const bettingLimits = ref({
|
||||
minStake: 1,
|
||||
@@ -219,11 +234,6 @@ async function openEdit(id: string) {
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeposit(row: PlayerRow) {
|
||||
depositForm.value = { userId: row.id, amount: 100, remark: t('user.deposit_remark_default') };
|
||||
depositVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
let payload: ReturnType<typeof buildCreatePlayerPayload>;
|
||||
try {
|
||||
@@ -311,30 +321,6 @@ async function submitEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDeposit() {
|
||||
if (depositForm.value.amount <= 0) {
|
||||
ElMessage.warning(t('msg.amount_gt_zero'));
|
||||
return;
|
||||
}
|
||||
depositLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/wallet/deposit', {
|
||||
userId: depositForm.value.userId,
|
||||
amount: depositForm.value.amount,
|
||||
remark: depositForm.value.remark,
|
||||
requestId: `dep-${depositForm.value.userId}-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
depositVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.topup_failed'));
|
||||
} finally {
|
||||
depositLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString(localeTag.value);
|
||||
@@ -554,11 +540,12 @@ function statusLabel(s: string) {
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.id)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.id)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDeposit(row)">{{ t('common.topup') }}</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>
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
size="small"
|
||||
@@ -760,21 +747,41 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="depositVisible" :title="t('user.dialog.deposit')" width="400px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input :model-value="depositForm.userId" disabled />
|
||||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
|
||||
<WalletTransferContext
|
||||
:context="transferContext"
|
||||
:mode="transferType"
|
||||
:loading="transferContextLoading"
|
||||
/>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item :label="t('common.col_id')">
|
||||
<span>{{ transferTarget?.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number v-model="depositForm.amount" :min="0.01" :step="10" style="width: 100%" />
|
||||
<el-input-number
|
||||
v-model="transferAmount"
|
||||
:min="transferAmountRange.min"
|
||||
:max="transferAmountRange.max"
|
||||
:disabled="transferAmountDisabled"
|
||||
:step="10"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="depositForm.remark" />
|
||||
<el-input v-model="transferRemark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="depositVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">{{ t('user.btn.confirm_deposit') }}</el-button>
|
||||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="transferLoading"
|
||||
:disabled="transferAmountDisabled"
|
||||
@click="submitTransfer"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
@@ -14,15 +14,33 @@ export interface AgentCreateForm {
|
||||
userId: string;
|
||||
creditLimit: number;
|
||||
cashbackRate: number;
|
||||
maxSingleDeposit: number;
|
||||
maxDailyDeposit: number;
|
||||
phone: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AgentEditForm {
|
||||
id: string;
|
||||
username: string;
|
||||
status: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
cashbackRate: number;
|
||||
maxSingleDeposit: number;
|
||||
maxDailyDeposit: number;
|
||||
managedPassword: string | null;
|
||||
newPassword: string;
|
||||
loginFailCount: number;
|
||||
lastLoginAt: string | null;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerCount: number;
|
||||
childAgentCount: number;
|
||||
level: number;
|
||||
parentUsername: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentRow {
|
||||
@@ -35,7 +53,10 @@ export interface AgentRow {
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerCount: number;
|
||||
childAgentCount: number;
|
||||
cashbackRate: string;
|
||||
maxSingleDeposit: string | null;
|
||||
maxDailyDeposit: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
locale: string;
|
||||
@@ -47,6 +68,8 @@ export interface AgentDetail extends AgentRow {
|
||||
parentUsername: string | null;
|
||||
directPlayerLiability: string;
|
||||
childAgentExposure: string;
|
||||
managedPassword: string | null;
|
||||
loginFailCount: number;
|
||||
lastLoginAt: string | null;
|
||||
updatedAt: string;
|
||||
recentCreditTransactions: {
|
||||
@@ -65,6 +88,8 @@ export function emptyAgentCreateForm(): AgentCreateForm {
|
||||
userId: '',
|
||||
creditLimit: 50000,
|
||||
cashbackRate: 0,
|
||||
maxSingleDeposit: 0,
|
||||
maxDailyDeposit: 0,
|
||||
phone: '',
|
||||
email: '',
|
||||
};
|
||||
@@ -72,19 +97,51 @@ export function emptyAgentCreateForm(): AgentCreateForm {
|
||||
|
||||
export function emptyAgentEditForm(): AgentEditForm {
|
||||
return {
|
||||
id: '',
|
||||
username: '',
|
||||
status: 'ACTIVE',
|
||||
phone: '',
|
||||
email: '',
|
||||
cashbackRate: 0,
|
||||
maxSingleDeposit: 0,
|
||||
maxDailyDeposit: 0,
|
||||
managedPassword: null,
|
||||
newPassword: '',
|
||||
loginFailCount: 0,
|
||||
lastLoginAt: null,
|
||||
creditLimit: '0',
|
||||
usedCredit: '0',
|
||||
availableCredit: '0',
|
||||
directPlayerCount: 0,
|
||||
childAgentCount: 0,
|
||||
level: 1,
|
||||
parentUsername: null,
|
||||
createdAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
|
||||
return {
|
||||
id: d.userId,
|
||||
username: d.username,
|
||||
status: d.status,
|
||||
phone: d.phone ?? '',
|
||||
email: d.email ?? '',
|
||||
cashbackRate: Number(d.cashbackRate),
|
||||
maxSingleDeposit: d.maxSingleDeposit ? Number(d.maxSingleDeposit) : 0,
|
||||
maxDailyDeposit: d.maxDailyDeposit ? Number(d.maxDailyDeposit) : 0,
|
||||
managedPassword: d.managedPassword ?? null,
|
||||
newPassword: '',
|
||||
loginFailCount: d.loginFailCount ?? 0,
|
||||
lastLoginAt: d.lastLoginAt ?? null,
|
||||
creditLimit: d.creditLimit,
|
||||
usedCredit: d.usedCredit,
|
||||
availableCredit: d.availableCredit,
|
||||
directPlayerCount: d.directPlayerCount,
|
||||
childAgentCount: d.childAgentCount ?? 0,
|
||||
level: d.level,
|
||||
parentUsername: d.parentUsername ?? null,
|
||||
createdAt: d.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +161,8 @@ export function buildCreateAgentPayload(form: AgentCreateForm) {
|
||||
userId: form.userId,
|
||||
creditLimit: form.creditLimit,
|
||||
cashbackRate: form.cashbackRate,
|
||||
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
|
||||
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -1,92 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
import type { AgentDashboard } from '../dashboard-types';
|
||||
import EChartPanel from '../../components/dashboard/EChartPanel.vue';
|
||||
import { buildCombinedTrendOption, buildTriplePieOption } from '../../utils/dashboard-charts';
|
||||
import { betStatusLabel } from '../../utils/bet-labels';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const summary = ref<Record<string, unknown>>({});
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/agent/reports/summary');
|
||||
summary.value = data.data;
|
||||
const stats = ref<AgentDashboard | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
try {
|
||||
const { data } = await api.get('/agent/reports/summary');
|
||||
stats.value = data.data as AgentDashboard;
|
||||
} catch {
|
||||
stats.value = null;
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
type KpiLink = { path: string; query?: Record<string, string> };
|
||||
|
||||
function goKpiLink(link: KpiLink) {
|
||||
router.push(link.query ? { path: link.path, query: link.query } : link.path);
|
||||
}
|
||||
|
||||
const s = computed(() => stats.value);
|
||||
|
||||
function fmtCount(val: number | undefined) {
|
||||
return (val ?? 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function toNum(v: string | number | undefined) {
|
||||
const n = typeof v === 'number' ? v : parseFloat(v ?? '0');
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function pctChange(today: string | number, yesterday: string | number) {
|
||||
const t0 = toNum(today);
|
||||
const y = toNum(yesterday);
|
||||
if (y === 0) return t0 > 0 ? '+100%' : '—';
|
||||
const p = ((t0 - y) / y) * 100;
|
||||
const sign = p > 0 ? '+' : '';
|
||||
return `${sign}${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const chartI18n = computed(() => ({
|
||||
locale: locale.value,
|
||||
betCountSeries: t('dash.chart_bet_count'),
|
||||
axisAmount: t('dash.axis_amount'),
|
||||
axisCount: t('dash.axis_count'),
|
||||
countSuffix: t('dash.count_suffix'),
|
||||
pieTooltip: t('dash.chart_tooltip'),
|
||||
noData: t('common.no_data'),
|
||||
pieEmpty: t('dash.pie_empty'),
|
||||
}));
|
||||
|
||||
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
|
||||
|
||||
const mainTrendOption = computed(() =>
|
||||
buildCombinedTrendOption(
|
||||
trendLabels.value,
|
||||
[
|
||||
{
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#248f54',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
s.value?.trend7d?.map((d) => d.betCount) ?? [],
|
||||
chartI18n.value,
|
||||
),
|
||||
);
|
||||
|
||||
const distributionOption = computed(() => {
|
||||
const c = s.value?.credit;
|
||||
const p = s.value?.players;
|
||||
const raw = s.value?.bets.todayByStatus ?? {};
|
||||
const betColors: Record<string, string> = {
|
||||
PENDING: '#fb923c',
|
||||
WON: '#248f54',
|
||||
LOST: '#f87171',
|
||||
VOID: '#6b7280',
|
||||
REFUNDED: '#60a5fa',
|
||||
};
|
||||
|
||||
const creditSegs = c
|
||||
? [
|
||||
{
|
||||
label: t('agent_dash.credit_available'),
|
||||
value: toNum(c.availableCredit),
|
||||
color: '#248f54',
|
||||
},
|
||||
{
|
||||
label: t('agent_dash.credit_used'),
|
||||
value: toNum(c.usedCredit),
|
||||
color: '#fb923c',
|
||||
},
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
const betSegs = ['PENDING', 'WON', 'LOST', 'VOID', 'REFUNDED']
|
||||
.filter((k) => raw[k]?.count)
|
||||
.map((k) => ({
|
||||
label: betStatusLabel(k),
|
||||
value: raw[k].count,
|
||||
color: betColors[k] ?? '#888',
|
||||
}));
|
||||
|
||||
const playerSegs = p
|
||||
? [
|
||||
{ label: t('dash.user_active'), value: p.active, color: '#248f54' },
|
||||
{ label: t('dash.user_suspended'), value: p.suspended, color: '#f87171' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
return buildTriplePieOption(
|
||||
[
|
||||
{ title: t('agent_dash.pie_credit'), segments: creditSegs },
|
||||
{ title: t('dash.pie_bets'), segments: betSegs },
|
||||
{ title: t('agent_dash.pie_players'), segments: playerSegs },
|
||||
],
|
||||
chartI18n.value,
|
||||
);
|
||||
});
|
||||
|
||||
function fmtCount(val: unknown) {
|
||||
return (Number(val) || 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
const kpiPrimary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
const td = s.value.today;
|
||||
const y = s.value.yesterday;
|
||||
const yLabel = t('common.yesterday');
|
||||
return [
|
||||
{
|
||||
label: t('dash.kpi_bet_count'),
|
||||
value: fmtCount(td.betCount),
|
||||
sub: `${yLabel} ${fmtCount(y.betCount)}`,
|
||||
delta: pctChange(td.betCount, y.betCount),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_stake'),
|
||||
value: formatAmount(td.stake, 2, locale.value),
|
||||
sub: formatAmountFull(td.stake, locale.value),
|
||||
delta: pctChange(td.stake, y.stake),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_payout'),
|
||||
value: formatAmount(td.payout, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.payout, 2, locale.value)}`,
|
||||
delta: pctChange(td.payout, y.payout),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_ggr'),
|
||||
value: formatAmount(td.ggr, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.ggr, 2, locale.value)}`,
|
||||
delta: pctChange(td.ggr, y.ggr),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const kpiSecondary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
const pending = s.value.bets.pendingTotal;
|
||||
return [
|
||||
{
|
||||
label: t('agent_dash.kpi_players'),
|
||||
value: `${fmtCount(s.value.players.directTotal)} / ${fmtCount(s.value.subAgents.total)}`,
|
||||
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
||||
link: { path: '/my-players' },
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_pending'),
|
||||
value: `${fmtCount(pending)} ${t('common.bets_unit')}`,
|
||||
sub: t('agent_dash.kpi_pending_sub', { bets: fmtCount(pending) }),
|
||||
link: pending > 0 ? { path: '/my-bets' } : undefined,
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_wallet'),
|
||||
value: formatAmount(s.value.wallets.totalAvailable, 2, locale.value),
|
||||
sub: `${t('common.frozen')} ${formatAmount(s.value.wallets.totalFrozen, 2, locale.value)}`,
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_credit'),
|
||||
value: formatAmount(s.value.credit.availableCredit, 2, locale.value),
|
||||
sub: `${t('common.used')} ${formatAmount(s.value.credit.usedCredit, 2, locale.value)}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
|
||||
</div>
|
||||
<div class="agent-dash" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-blue">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">{{ t('agent.credit_limit') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
<el-card v-if="!loading && loadError" class="state-card" shadow="never">
|
||||
<p class="state-title">{{ t('msg.load_failed') }}</p>
|
||||
<p class="state-hint">{{ t('agent_dash.load_error_hint') }}</p>
|
||||
<el-button type="primary" size="small" @click="load">{{ t('common.retry') }}</el-button>
|
||||
</el-card>
|
||||
|
||||
<template v-else-if="s">
|
||||
<el-card class="overview-board" shadow="never">
|
||||
<div v-if="s.generatedAt" class="board-head">
|
||||
<span class="board-hint">{{ t('agent_dash.board_hint') }}</span>
|
||||
<span class="dash-updated">
|
||||
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { creditLimit?: string })?.creditLimit) }}</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-orange">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">{{ t('agent.credit_used') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
|
||||
<div class="kpi-grid kpi-primary">
|
||||
<div v-for="item in kpiPrimary" :key="item.label" class="kpi-cell">
|
||||
<span class="kpi-label">{{ item.label }}</span>
|
||||
<span class="kpi-value">{{ item.value }}</span>
|
||||
<span class="kpi-sub">{{ item.sub }}</span>
|
||||
<span
|
||||
class="kpi-delta"
|
||||
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
|
||||
>
|
||||
{{ t('common.vs_yesterday') }} {{ item.delta }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { usedCredit?: string })?.usedCredit) }}</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_occupied') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-green">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">{{ t('agent.direct_players') }}</span>
|
||||
<span class="stat-badge">{{ t('common.people') }}</span>
|
||||
|
||||
<div class="kpi-grid kpi-secondary">
|
||||
<div
|
||||
v-for="item in kpiSecondary"
|
||||
:key="item.label"
|
||||
class="kpi-cell compact"
|
||||
:class="{ 'kpi-cell--link': item.link }"
|
||||
:role="item.link ? 'button' : undefined"
|
||||
:tabindex="item.link ? 0 : undefined"
|
||||
@click="item.link && goKpiLink(item.link)"
|
||||
@keydown.enter.prevent="item.link && goKpiLink(item.link)"
|
||||
>
|
||||
<span class="kpi-label">{{ item.label }}</span>
|
||||
<span class="kpi-value sm">{{ item.value }}</span>
|
||||
<span class="kpi-sub">{{ item.sub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ fmtCount(summary.directPlayerCount) }}</div>
|
||||
<div class="stat-foot">{{ t('agent.player_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-purple">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">{{ t('agent.today_stake') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
|
||||
<div class="charts-stack">
|
||||
<EChartPanel title="" :option="mainTrendOption" height="300px" class="chart-main" />
|
||||
<div class="chart-main-caption">{{ t('dash.trend_caption') }}</div>
|
||||
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.todayStake) }}</div>
|
||||
<div class="stat-foot">{{ t('agent.currency_cny') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.agent-dash { padding-bottom: 32px; }
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
padding: 22px 20px 18px;
|
||||
border: 1px solid #1e1e1e;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid #2a2220;
|
||||
background: rgba(255, 69, 58, 0.06);
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
.state-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #ff8a80;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.state-hint {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.overview-board {
|
||||
border-radius: 14px;
|
||||
border: 1px solid #1e1e1e;
|
||||
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
|
||||
}
|
||||
.overview-board :deep(.el-card__body) {
|
||||
padding: 20px 22px 16px;
|
||||
}
|
||||
.board-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: -4px 0 14px;
|
||||
}
|
||||
.board-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.dash-updated {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.kpi-primary {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.kpi-secondary {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.kpi-cell {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #222;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.kpi-cell.compact {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.kpi-cell--link {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.kpi-cell--link:hover,
|
||||
.kpi-cell--link:focus-visible {
|
||||
border-color: rgba(77, 214, 138, 0.35);
|
||||
background: rgba(36, 143, 84, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--green-text);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.kpi-value.sm {
|
||||
font-size: 17px;
|
||||
}
|
||||
.kpi-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.kpi-delta {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.kpi-delta.up { color: #4ade80; }
|
||||
.kpi-delta.down { color: #f87171; }
|
||||
|
||||
.charts-stack {
|
||||
border-top: 1px solid #1a1a1a;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.chart-main-caption {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
margin: -8px 0 8px;
|
||||
}
|
||||
.charts-stack :deep(.chart-panel) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.charts-stack :deep(.chart-title:empty) {
|
||||
display: none;
|
||||
}
|
||||
.chart-dist {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-primary,
|
||||
.kpi-secondary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.kpi-primary,
|
||||
.kpi-secondary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); border-color: #2a2a2a; }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.stat-label { font-size: 12px; color: #666; font-weight: 600; }
|
||||
.stat-badge { font-size: 11px; color: #444; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; color: var(--green-text); margin-bottom: 6px; }
|
||||
.stat-foot { font-size: 11px; color: #444; }
|
||||
.c-blue { background: linear-gradient(135deg, rgba(96, 165, 250, 0.08), transparent); }
|
||||
.c-orange { background: linear-gradient(135deg, rgba(251, 146, 60, 0.08), transparent); }
|
||||
.c-green { background: linear-gradient(135deg, rgba(36, 143, 84, 0.1), transparent); }
|
||||
.c-purple { background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), transparent); }
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
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 {
|
||||
depositAmountCap,
|
||||
parsePlayerAvailable,
|
||||
type WalletTransferContext as WalletTransferContextData,
|
||||
} from '../../utils/wallet-transfer-context';
|
||||
import {
|
||||
snapshotFromAgentRow,
|
||||
type AgentCreditAdjustContext,
|
||||
} from '../../utils/agent-credit-context';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const auth = useAuthStore();
|
||||
|
||||
type PlayerRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
wallet?: { availableBalance: string };
|
||||
};
|
||||
/* L1 agents can manage sub-agents; L2 cannot */
|
||||
const isTier1 = computed(() => auth.isTier1Agent.value);
|
||||
|
||||
const players = ref<PlayerRow[]>([]);
|
||||
const form = ref({ username: '', password: 'Player@123' });
|
||||
/* ─── Credit profile ─── */
|
||||
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
|
||||
|
||||
/* ─── 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<PlayerRow | null>(null);
|
||||
const transferTarget = ref<AgentPlayerRow | null>(null);
|
||||
const transferAmount = ref(100);
|
||||
const transferContext = ref<WalletTransferContextData | null>(null);
|
||||
const transferContextLoading = ref(false);
|
||||
|
||||
onMounted(load);
|
||||
/* ─── Sub-agents ─── */
|
||||
const subAgentKeyword = ref('');
|
||||
const subAgents = ref<AgentSubAgentRow[]>([]);
|
||||
const loadingAgents = ref(false);
|
||||
const subAgentTableRef = ref<TableInstance>();
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/agent/players');
|
||||
players.value = data.data as PlayerRow[];
|
||||
/* 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;
|
||||
});
|
||||
|
||||
/* ─── Init ─── */
|
||||
onMounted(async () => {
|
||||
await loadProfile();
|
||||
await loadPlayers();
|
||||
if (isTier1.value) {
|
||||
await loadSubAgents();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const { data } = await api.get('/agent/profile');
|
||||
profile.value = data.data;
|
||||
} catch { /* empty */ }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.value.username.trim()) {
|
||||
ElMessage.warning(t('err.username_required'));
|
||||
async function loadPlayers() {
|
||||
loadingPlayers.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/agent/players');
|
||||
players.value = data.data as AgentPlayerRow[];
|
||||
} finally {
|
||||
loadingPlayers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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', form.value);
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
form.value.username = '';
|
||||
load();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
|
||||
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;
|
||||
transferAmount.value = 100;
|
||||
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'));
|
||||
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;
|
||||
@@ -71,7 +363,8 @@ async function submitTransfer() {
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
}
|
||||
transferVisible.value = false;
|
||||
load();
|
||||
loadPlayers();
|
||||
loadProfile();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
|
||||
@@ -86,32 +379,222 @@ function transferTitle() {
|
||||
? 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?.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">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_players.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_players.desc') }}</span>
|
||||
<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>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<!-- ─── 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-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" :label="t('common.col_id')" width="72" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<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">
|
||||
@@ -122,74 +605,410 @@ function transferTitle() {
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="168" align="center" fixed="right">
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
<div class="action-btns">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</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>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="360px" destroy-on-close>
|
||||
<el-form label-width="72px">
|
||||
<!-- ══════ Tab: 下级代理 (仅一级代理可见) ══════ -->
|
||||
<el-tab-pane v-if="isTier1" :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('agent_portal.username_ph')" />
|
||||
</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_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="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')">
|
||||
<el-input-number
|
||||
v-model="transferAmount"
|
||||
:min="0.01"
|
||||
:step="10"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:min="transferAmountRange.min" :max="transferAmountRange.max"
|
||||
: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" @click="submitTransfer">
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled" @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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
/* ─── 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));
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
.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;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #3a3a3a;
|
||||
|
||||
/* ─── 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);
|
||||
}
|
||||
.tool-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
.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);
|
||||
}
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.tool-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
.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>
|
||||
|
||||
@@ -1,26 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
import { resolveFormError } from '../../i18n/form-validation';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import AgentCreditContext from '../../components/AgentCreditContext.vue';
|
||||
import {
|
||||
snapshotFromAgentRow,
|
||||
type AgentCreditAdjustContext,
|
||||
} from '../../utils/agent-credit-context';
|
||||
import {
|
||||
emptyAgentSubAgentCreateForm,
|
||||
buildAgentSubAgentCreatePayload,
|
||||
type AgentSubAgentRow,
|
||||
} from './agent-sub-agent-form';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const agents = ref<unknown[]>([]);
|
||||
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
|
||||
const agents = ref<AgentSubAgentRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
|
||||
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
|
||||
|
||||
const createVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createForm = ref(emptyAgentSubAgentCreateForm());
|
||||
|
||||
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);
|
||||
|
||||
const availableCreditNum = computed(() => {
|
||||
const n = Number(profile.value.availableCredit ?? 0);
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
});
|
||||
|
||||
const createCreditRange = computed(() => ({
|
||||
min: 0,
|
||||
max: availableCreditNum.value,
|
||||
}));
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase();
|
||||
if (!q) return agents.value;
|
||||
return agents.value.filter(
|
||||
(a) =>
|
||||
a.username.toLowerCase().includes(q) ||
|
||||
a.userId.includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/agent/agents');
|
||||
agents.value = data.data;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [agentsRes, profileRes] = await Promise.all([
|
||||
api.get('/agent/agents'),
|
||||
api.get('/agent/profile'),
|
||||
]);
|
||||
agents.value = (agentsRes.data.data ?? []) as AgentSubAgentRow[];
|
||||
profile.value = profileRes.data.data as typeof profile.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/agents', form.value);
|
||||
ElMessage.success(t('msg.agent_sub_created'));
|
||||
load();
|
||||
function openCreate() {
|
||||
createForm.value = emptyAgentSubAgentCreateForm();
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
let payload: ReturnType<typeof buildAgentSubAgentCreatePayload>;
|
||||
try {
|
||||
payload = buildAgentSubAgentCreatePayload(createForm.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.creditLimit > availableCreditNum.value) {
|
||||
ElMessage.warning(t('err.insufficient_credit'));
|
||||
return;
|
||||
}
|
||||
|
||||
createLoading.value = true;
|
||||
const password = createForm.value.password;
|
||||
try {
|
||||
await api.post('/agent/agents', payload);
|
||||
ElMessage.success(t('user.msg.created_with_password', { password }));
|
||||
createVisible.value = false;
|
||||
load();
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
function openCredit(row: AgentSubAgentRow) {
|
||||
creditTarget.value = row;
|
||||
creditAmount.value = 10000;
|
||||
creditRemark.value = '';
|
||||
creditContext.value = {
|
||||
target: snapshotFromAgentRow(row),
|
||||
parent: snapshotFromAgentRow({
|
||||
username: auth.user?.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 submitCredit() {
|
||||
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;
|
||||
load();
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,52 +185,256 @@ async function create() {
|
||||
<span class="page-desc">{{ t('page.agent_sub.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.agent_username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.credit_limit')">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_tier2_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<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>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="list-chrome">
|
||||
<div class="list-chrome__row">
|
||||
<el-form inline class="list-chrome__grow">
|
||||
<el-form-item :label="t('common.search')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="t('agent_portal.search_sub_agent_ph')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="list-chrome__actions">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
{{ t('agent_portal.create_tier2_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="data-card">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column :label="t('user.col.username')" min-width="140">
|
||||
<el-table v-loading="loading" :data="filteredAgents" stripe>
|
||||
<el-table-column prop="userId" :label="t('common.col_id')" width="72" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('agent.col.level')" width="72" align="center">
|
||||
<template #default="{ row }">L{{ row.level }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ (row as { user?: { username: string } }).user?.username }}
|
||||
<el-tag
|
||||
:type="row.userStatus === 'ACTIVE' ? 'success' : 'info'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ statusLabel(row.userStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.credit_limit')" min-width="100" align="right">
|
||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
|
||||
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
|
||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.used_credit')" min-width="100" align="right">
|
||||
<el-table-column :label="t('agent.col.direct_players')" width="96" align="center">
|
||||
<template #default="{ row }">{{ row.directPlayerCount }}</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="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
|
||||
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
|
||||
</el-tooltip>
|
||||
<el-button size="small" type="primary" link @click="openCredit(row)">
|
||||
{{ t('common.adjust_credit') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div class="empty-hint">{{ t('agent_portal.no_sub_agents') }}</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="createVisible"
|
||||
:title="t('agent_portal.create_sub_agent_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('agent_portal.agent_username_ph')" />
|
||||
</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('agent.field.credit_limit')" required>
|
||||
<el-input-number
|
||||
v-model="createForm.creditLimit"
|
||||
:min="createCreditRange.min"
|
||||
:max="createCreditRange.max"
|
||||
:step="1000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="field-hint">{{ t('agent_portal.sub_agent_credit_hint') }}</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%" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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="submitCredit">
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.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, 0.6), rgba(20, 20, 20, 0.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;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #1e1e1e;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.amount-compact {
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.create-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.create-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.empty-hint {
|
||||
padding: 32px 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
133
apps/admin/src/views/agent/agent-player-form.ts
Normal file
133
apps/admin/src/views/agent/agent-player-form.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FormValidationError } from '../../i18n/form-validation';
|
||||
|
||||
export interface AgentPlayerCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
initialDeposit: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface AgentPlayerRow {
|
||||
id: string;
|
||||
username: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
wallet?: { availableBalance: string; frozenBalance?: string };
|
||||
}
|
||||
|
||||
export interface AgentPlayerDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
status: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword: string | null;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
lastLoginAt: string | null;
|
||||
loginFailCount: number;
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
totalReturn: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentPlayerEditForm {
|
||||
id: string;
|
||||
username: string;
|
||||
status: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
managedPassword: string | null;
|
||||
newPassword: string;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
totalReturn: string;
|
||||
lastLoginAt: string | null;
|
||||
loginFailCount: number;
|
||||
}
|
||||
|
||||
export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
|
||||
return {
|
||||
username: '',
|
||||
password: 'Player@123',
|
||||
confirmPassword: 'Player@123',
|
||||
phone: '',
|
||||
email: '',
|
||||
initialDeposit: 0,
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||||
if (form.initialDeposit < 0) throw new FormValidationError('err.amount_negative');
|
||||
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
|
||||
remark: form.remark.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyAgentPlayerEditForm(): AgentPlayerEditForm {
|
||||
return {
|
||||
id: '',
|
||||
username: '',
|
||||
status: 'ACTIVE',
|
||||
phone: '',
|
||||
email: '',
|
||||
managedPassword: null,
|
||||
newPassword: '',
|
||||
availableBalance: '0',
|
||||
frozenBalance: '0',
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
totalReturn: '0',
|
||||
lastLoginAt: null,
|
||||
loginFailCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function editFormFromAgentDetail(d: AgentPlayerDetail): AgentPlayerEditForm {
|
||||
return {
|
||||
id: d.id,
|
||||
username: d.username,
|
||||
status: d.status,
|
||||
phone: d.phone ?? '',
|
||||
email: d.email ?? '',
|
||||
managedPassword: d.managedPassword,
|
||||
newPassword: '',
|
||||
availableBalance: d.availableBalance,
|
||||
frozenBalance: d.frozenBalance,
|
||||
betCount: d.betCount,
|
||||
totalStake: d.totalStake,
|
||||
totalReturn: d.totalReturn,
|
||||
lastLoginAt: d.lastLoginAt,
|
||||
loginFailCount: d.loginFailCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentUpdatePlayerPayload(form: AgentPlayerEditForm) {
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
if (form.newPassword && form.newPassword.length < 8) {
|
||||
throw new FormValidationError('err.password_min');
|
||||
}
|
||||
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
password: form.newPassword.trim() || undefined,
|
||||
};
|
||||
}
|
||||
151
apps/admin/src/views/agent/agent-sub-agent-form.ts
Normal file
151
apps/admin/src/views/agent/agent-sub-agent-form.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { FormValidationError } from '../../i18n/form-validation';
|
||||
|
||||
export interface AgentSubAgentRow {
|
||||
userId: string;
|
||||
username: string;
|
||||
userStatus: string;
|
||||
status: string;
|
||||
level: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentSubAgentDetail {
|
||||
userId: string;
|
||||
username: string;
|
||||
userStatus: string;
|
||||
status: string;
|
||||
level: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerCount: number;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword: string | null;
|
||||
loginFailCount: number;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSubAgentEditForm {
|
||||
userId: string;
|
||||
username: string;
|
||||
status: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
managedPassword: string | null;
|
||||
newPassword: string;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerCount: number;
|
||||
loginFailCount: number;
|
||||
lastLoginAt: string | null;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface AgentSubAgentCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
creditLimit: number;
|
||||
cashbackRate: number;
|
||||
maxSingleDeposit: number;
|
||||
maxDailyDeposit: number;
|
||||
}
|
||||
|
||||
export function emptyAgentSubAgentCreateForm(): AgentSubAgentCreateForm {
|
||||
return {
|
||||
username: '',
|
||||
password: 'Agent@123',
|
||||
confirmPassword: 'Agent@123',
|
||||
creditLimit: 10000,
|
||||
cashbackRate: 0,
|
||||
maxSingleDeposit: 0,
|
||||
maxDailyDeposit: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentSubAgentCreatePayload(form: AgentSubAgentCreateForm) {
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||||
if (form.creditLimit < 0) throw new FormValidationError('err.amount_negative');
|
||||
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
creditLimit: form.creditLimit,
|
||||
cashbackRate: form.cashbackRate,
|
||||
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
|
||||
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyAgentSubAgentEditForm(): AgentSubAgentEditForm {
|
||||
return {
|
||||
userId: '',
|
||||
username: '',
|
||||
status: 'ACTIVE',
|
||||
phone: '',
|
||||
email: '',
|
||||
managedPassword: null,
|
||||
newPassword: '',
|
||||
creditLimit: '0',
|
||||
usedCredit: '0',
|
||||
availableCredit: '0',
|
||||
directPlayerCount: 0,
|
||||
loginFailCount: 0,
|
||||
lastLoginAt: null,
|
||||
level: 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function editFormFromSubAgentDetail(d: AgentSubAgentDetail): AgentSubAgentEditForm {
|
||||
return {
|
||||
userId: d.userId,
|
||||
username: d.username,
|
||||
status: d.userStatus ?? d.status,
|
||||
phone: d.phone ?? '',
|
||||
email: d.email ?? '',
|
||||
managedPassword: d.managedPassword ?? null,
|
||||
newPassword: '',
|
||||
creditLimit: d.creditLimit,
|
||||
usedCredit: d.usedCredit,
|
||||
availableCredit: d.availableCredit,
|
||||
directPlayerCount: d.directPlayerCount,
|
||||
loginFailCount: d.loginFailCount ?? 0,
|
||||
lastLoginAt: d.lastLoginAt ?? null,
|
||||
level: d.level,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentSubAgentUpdatePayload(form: AgentSubAgentEditForm) {
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
const payload: {
|
||||
username: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
password?: string;
|
||||
} = {
|
||||
username: form.username.trim(),
|
||||
status: form.status,
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
};
|
||||
const newPwd = form.newPassword.trim();
|
||||
if (newPwd) {
|
||||
if (newPwd.length < 8) throw new FormValidationError('err.password_min');
|
||||
payload.password = newPwd;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Prefer account lock status over profile status for list/actions. */
|
||||
export function subAgentAccountStatus(row: Pick<AgentSubAgentRow, 'userStatus' | 'status'>) {
|
||||
return row.userStatus ?? row.status;
|
||||
}
|
||||
@@ -69,3 +69,60 @@ export interface AdminDashboard {
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AgentDashboard {
|
||||
generatedAt: string;
|
||||
trend7d: DashboardTrendDay[];
|
||||
today: {
|
||||
betCount: number;
|
||||
stake: string;
|
||||
payout: string;
|
||||
ggr: string;
|
||||
newPlayers: number;
|
||||
};
|
||||
yesterday: {
|
||||
betCount: number;
|
||||
stake: string;
|
||||
payout: string;
|
||||
ggr: string;
|
||||
};
|
||||
players: {
|
||||
directTotal: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
newToday: number;
|
||||
};
|
||||
subAgents: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
wallets: {
|
||||
totalAvailable: string;
|
||||
totalFrozen: string;
|
||||
playerWalletCount: number;
|
||||
};
|
||||
credit: {
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerLiability: string;
|
||||
childAgentExposure: string;
|
||||
};
|
||||
bets: {
|
||||
pendingTotal: number;
|
||||
todayByStatus: Record<string, { count: number; stake: string }>;
|
||||
};
|
||||
recentBets: {
|
||||
betNo: string;
|
||||
username: string;
|
||||
stake: string;
|
||||
status: string;
|
||||
placedAt: string;
|
||||
}[];
|
||||
recentPlayers: {
|
||||
id: string;
|
||||
username: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface PlayerCreateForm {
|
||||
asTier1Agent: boolean;
|
||||
creditLimit: number;
|
||||
cashbackRate: number;
|
||||
maxSingleDeposit: number;
|
||||
maxDailyDeposit: number;
|
||||
}
|
||||
|
||||
export interface PlayerEditForm {
|
||||
@@ -73,6 +75,8 @@ export function emptyPlayerCreateForm(): PlayerCreateForm {
|
||||
asTier1Agent: false,
|
||||
creditLimit: 50000,
|
||||
cashbackRate: 0,
|
||||
maxSingleDeposit: 0,
|
||||
maxDailyDeposit: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +140,8 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
|
||||
asTier1Agent: true,
|
||||
creditLimit: form.creditLimit,
|
||||
cashbackRate: form.cashbackRate,
|
||||
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
|
||||
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -101,6 +101,15 @@ class CreatePlayerAdminDto {
|
||||
@IsOptional()
|
||||
asTier1Agent?: boolean;
|
||||
|
||||
/** 创建为二级代理(需要 parentAgentId) */
|
||||
@IsOptional()
|
||||
asSubAgent?: boolean;
|
||||
|
||||
/** 二级代理的上级代理 ID */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentAgentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -110,6 +119,16 @@ class CreatePlayerAdminDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSingleDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDailyDeposit?: number;
|
||||
}
|
||||
|
||||
class UpdatePlayerAdminDto {
|
||||
@@ -154,6 +173,16 @@ class PlayerAccountSettingsDto {
|
||||
allowUsernameChange?: boolean;
|
||||
}
|
||||
|
||||
class AgentSuspendSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
suspendFreezeDirectPlayers?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
suspendBlockPlayerLogin?: boolean;
|
||||
}
|
||||
|
||||
class ResetDatabaseDto {
|
||||
@IsString()
|
||||
@Equals('RESET')
|
||||
@@ -181,6 +210,16 @@ class CreateAgentAdminDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSingleDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDailyDeposit?: number;
|
||||
}
|
||||
|
||||
class UpdateAgentAdminDto {
|
||||
@@ -204,6 +243,29 @@ class UpdateAgentAdminDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSingleDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDailyDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
/** 冻结时是否级联冻结直属玩家 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
freezeDirectPlayers?: boolean;
|
||||
}
|
||||
|
||||
class DepositDto {
|
||||
@@ -717,6 +779,30 @@ export class AdminController {
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Get('agents/settings/suspend')
|
||||
@RequirePermissions(P.settings)
|
||||
async getAgentSuspendSettings() {
|
||||
const settings = await this.systemConfig.getAgentSuspendSettings();
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Put('agents/settings/suspend')
|
||||
@RequirePermissions(P.settings)
|
||||
async updateAgentSuspendSettings(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: AgentSuspendSettingsDto,
|
||||
) {
|
||||
const settings = await this.systemConfig.updateAgentSuspendSettings(dto);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'UPDATE_AGENT_SUSPEND_SETTINGS',
|
||||
module: 'AGENTS',
|
||||
afterData: JSON.stringify(settings),
|
||||
});
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Get('settings/betting-limits')
|
||||
@RequirePermissions(P.settings)
|
||||
async getBettingLimits() {
|
||||
@@ -830,17 +916,21 @@ export class AdminController {
|
||||
depositRemark: dto.remark,
|
||||
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
|
||||
asTier1Agent: dto.asTier1Agent,
|
||||
asSubAgent: dto.asSubAgent,
|
||||
parentAgentId: dto.parentAgentId ? BigInt(dto.parentAgentId) : undefined,
|
||||
creditLimit: dto.creditLimit,
|
||||
cashbackRate: dto.cashbackRate,
|
||||
maxSingleDeposit: dto.maxSingleDeposit,
|
||||
maxDailyDeposit: dto.maxDailyDeposit,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: dto.asTier1Agent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
|
||||
module: dto.asTier1Agent ? 'AGENTS' : 'USERS',
|
||||
action: dto.asTier1Agent || dto.asSubAgent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
|
||||
module: dto.asTier1Agent || dto.asSubAgent ? 'AGENTS' : 'USERS',
|
||||
targetId: user.id.toString(),
|
||||
});
|
||||
if (dto.asTier1Agent) {
|
||||
if (dto.asTier1Agent || dto.asSubAgent) {
|
||||
const detail = await this.agents.getAgentAdminDetail(user.id);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
@@ -884,11 +974,13 @@ export class AdminController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('parentAgentId') parentAgentId?: string,
|
||||
) {
|
||||
const result = await this.agents.listAgentsAdmin({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||
keyword,
|
||||
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
@@ -929,6 +1021,8 @@ export class AdminController {
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
cashbackRate: dto.cashbackRate,
|
||||
maxSingleDeposit: dto.maxSingleDeposit,
|
||||
maxDailyDeposit: dto.maxDailyDeposit,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
@@ -961,7 +1055,7 @@ export class AdminController {
|
||||
@Post('wallet/deposit')
|
||||
@RequirePermissions(P.walletDeposit)
|
||||
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
const result = await this.wallet.deposit(
|
||||
const result = await this.agents.adminDepositToPlayer(
|
||||
BigInt(dto.userId),
|
||||
dto.amount,
|
||||
operatorId,
|
||||
@@ -971,6 +1065,13 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('wallet/transfer-context/:userId')
|
||||
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
|
||||
async walletTransferContext(@Param('userId') userId: string) {
|
||||
const ctx = await this.agents.getPlayerTransferContext(BigInt(userId), { forAdmin: true });
|
||||
return jsonResponse(ctx);
|
||||
}
|
||||
|
||||
@Post('wallet/withdraw')
|
||||
@RequirePermissions(P.walletWithdraw)
|
||||
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -15,7 +16,7 @@ import { AgentsService } from '../../domains/agent/agents.service';
|
||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||
import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { IsString, IsNumber, MinLength, IsOptional } from 'class-validator';
|
||||
import { IsString, IsNumber, MinLength, IsOptional, Min, IsBoolean } from 'class-validator';
|
||||
|
||||
class CreatePlayerDto {
|
||||
@IsString()
|
||||
@@ -24,12 +25,71 @@ class CreatePlayerDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
initialDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class CreateSubAgentDto extends CreatePlayerDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
creditLimit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSingleDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDailyDeposit?: number;
|
||||
}
|
||||
|
||||
class UpdatePlayerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
class TransferDto {
|
||||
@@ -46,6 +106,33 @@ class CreditDto extends TransferDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class UpdateSubAgentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
freezeDirectPlayers?: boolean;
|
||||
}
|
||||
|
||||
@ApiTags('Agent Portal')
|
||||
@Controller('agent')
|
||||
@UseGuards(JwtAuthGuard, AgentGuard)
|
||||
@@ -76,8 +163,57 @@ export class AgentPortalController {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
parentId: agentId,
|
||||
locale: dto.locale,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
});
|
||||
return jsonResponse(user);
|
||||
|
||||
if (dto.initialDeposit != null && dto.initialDeposit > 0) {
|
||||
await this.agents.depositToPlayer(
|
||||
agentId,
|
||||
user.id,
|
||||
dto.initialDeposit,
|
||||
`agent-create-${user.id}-${Date.now()}`,
|
||||
dto.remark ?? '开户初始余额',
|
||||
);
|
||||
}
|
||||
|
||||
const wallet = await this.prisma.wallet.findUnique({ where: { userId: user.id } });
|
||||
|
||||
return jsonResponse({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
status: user.status,
|
||||
createdAt: user.createdAt,
|
||||
availableBalance: wallet?.availableBalance ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('players/:id')
|
||||
async getPlayer(@CurrentUser('id') agentId: bigint, @Param('id') playerId: string) {
|
||||
const detail = await this.agents.getDirectPlayerDetail(agentId, BigInt(playerId));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Get('players/:id/transfer-context')
|
||||
async getPlayerTransferContext(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Param('id') playerId: string,
|
||||
) {
|
||||
const ctx = await this.agents.getPlayerTransferContext(BigInt(playerId), {
|
||||
actingAgentId: agentId,
|
||||
});
|
||||
return jsonResponse(ctx);
|
||||
}
|
||||
|
||||
@Put('players/:id')
|
||||
async updatePlayer(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Param('id') playerId: string,
|
||||
@Body() dto: UpdatePlayerDto,
|
||||
) {
|
||||
const detail = await this.agents.updateDirectPlayer(agentId, BigInt(playerId), dto);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
@@ -85,7 +221,7 @@ export class AgentPortalController {
|
||||
if (level !== 1) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
const agents = await this.agents.getChildAgents(agentId);
|
||||
const agents = await this.agents.listChildAgentsSummary(agentId);
|
||||
return jsonResponse(agents);
|
||||
}
|
||||
|
||||
@@ -100,6 +236,9 @@ export class AgentPortalController {
|
||||
level: 2,
|
||||
parentAgentId: agentId,
|
||||
creditLimit: dto.creditLimit,
|
||||
cashbackRate: dto.cashbackRate,
|
||||
maxSingleDeposit: dto.maxSingleDeposit,
|
||||
maxDailyDeposit: dto.maxDailyDeposit,
|
||||
});
|
||||
return jsonResponse(user);
|
||||
}
|
||||
@@ -124,6 +263,47 @@ export class AgentPortalController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('agents/:id')
|
||||
async getSubAgent(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@CurrentUser('agentLevel') level: number,
|
||||
@Param('id') subAgentId: string,
|
||||
) {
|
||||
if (level !== 1) {
|
||||
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
|
||||
}
|
||||
const detail = await this.agents.getSubAgentForParent(agentId, BigInt(subAgentId));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Put('agents/:id')
|
||||
async updateSubAgent(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@CurrentUser('agentLevel') level: number,
|
||||
@Param('id') subAgentId: string,
|
||||
@Body() dto: UpdateSubAgentDto,
|
||||
) {
|
||||
if (level !== 1) {
|
||||
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
|
||||
}
|
||||
const detail = await this.agents.updateSubAgentForParent(agentId, BigInt(subAgentId), dto);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Get('agents/:id/players')
|
||||
async listSubAgentPlayers(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@CurrentUser('agentLevel') level: number,
|
||||
@Param('id') subAgentId: string,
|
||||
) {
|
||||
if (level !== 1) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
|
||||
const players = await this.agents.getDirectPlayers(BigInt(subAgentId));
|
||||
return jsonResponse(players);
|
||||
}
|
||||
|
||||
@Post('agents/:id/credit')
|
||||
async allocateCredit(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
|
||||
@@ -250,8 +250,9 @@ export class PlayerController {
|
||||
async transactions(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Query('page') page?: string,
|
||||
@Query('type') type?: string,
|
||||
) {
|
||||
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1);
|
||||
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1, 20, type);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AuthModule } from '../identity/auth.module';
|
||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AuthModule],
|
||||
imports: [WalletModule, AuthModule, SystemConfigModule],
|
||||
providers: [AgentsService],
|
||||
exports: [AgentsService],
|
||||
})
|
||||
|
||||
@@ -4,19 +4,30 @@ import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { AuthService } from '../identity/auth.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../shared/common/decorators';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||||
return new Decimal(a ?? 0).sub(b ?? 0).toString();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private auth: AuthService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
@@ -84,6 +95,10 @@ export class AgentsService {
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
@@ -111,16 +126,275 @@ export class AgentsService {
|
||||
return { creditAfter };
|
||||
}
|
||||
|
||||
/** 代理只能操作直属玩家(parentId === 当前代理) */
|
||||
private async requireDirectPlayer(agentId: bigint, playerId: bigint) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { auth: true, wallet: true, preferences: true },
|
||||
});
|
||||
if (!player) throw new NotFoundException('玩家不存在');
|
||||
if (player.parentId !== agentId) {
|
||||
throw new ForbiddenException('Can only manage direct players');
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private async assertChildAgentWithinParent(
|
||||
parentAgentId: bigint,
|
||||
child: {
|
||||
creditLimit?: number | Decimal;
|
||||
cashbackRate?: number | Decimal;
|
||||
maxSingleDeposit?: number | Decimal | null;
|
||||
maxDailyDeposit?: number | Decimal | null;
|
||||
},
|
||||
) {
|
||||
const parent = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
});
|
||||
if (!parent) throw new BadRequestException('上级代理不存在');
|
||||
|
||||
if (child.creditLimit !== undefined) {
|
||||
const limit = new Decimal(child.creditLimit);
|
||||
if (limit.lt(0)) throw new BadRequestException('授信额度不能为负');
|
||||
if (limit.gt(parent.creditLimit)) {
|
||||
throw new BadRequestException('下级代理授信不能超过上级授信额度');
|
||||
}
|
||||
}
|
||||
|
||||
if (child.cashbackRate !== undefined) {
|
||||
const rate = new Decimal(child.cashbackRate);
|
||||
if (rate.lt(0)) throw new BadRequestException('回水比例不能为负');
|
||||
if (rate.gt(parent.cashbackRate)) {
|
||||
throw new BadRequestException('下级代理回水比例不能超过上级');
|
||||
}
|
||||
}
|
||||
|
||||
if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
|
||||
if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
|
||||
throw new BadRequestException('下级代理单笔限额不能超过上级');
|
||||
}
|
||||
}
|
||||
if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
|
||||
throw new BadRequestException('单笔限额不能为负');
|
||||
}
|
||||
|
||||
if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
|
||||
if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
|
||||
throw new BadRequestException('下级代理日限额不能超过上级');
|
||||
}
|
||||
}
|
||||
if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
|
||||
throw new BadRequestException('日限额不能为负');
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEffectiveDepositLimits(
|
||||
profile: {
|
||||
maxSingleDeposit: Decimal | null;
|
||||
maxDailyDeposit: Decimal | null;
|
||||
},
|
||||
parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
|
||||
) {
|
||||
let maxSingleDeposit = profile.maxSingleDeposit;
|
||||
let maxDailyDeposit = profile.maxDailyDeposit;
|
||||
|
||||
if (parent) {
|
||||
if (parent.maxSingleDeposit != null) {
|
||||
maxSingleDeposit =
|
||||
maxSingleDeposit != null
|
||||
? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
|
||||
: parent.maxSingleDeposit;
|
||||
}
|
||||
if (parent.maxDailyDeposit != null) {
|
||||
maxDailyDeposit =
|
||||
maxDailyDeposit != null
|
||||
? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
|
||||
: parent.maxDailyDeposit;
|
||||
}
|
||||
}
|
||||
|
||||
return { maxSingleDeposit, maxDailyDeposit };
|
||||
}
|
||||
|
||||
private normalizeOptionalLimit(value?: number | null) {
|
||||
if (value == null || value <= 0) return null;
|
||||
return new Decimal(value);
|
||||
}
|
||||
|
||||
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit) */
|
||||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!user?.parentId) return;
|
||||
|
||||
await this.recalculateUsedCredit(user.parentId);
|
||||
const profile = await this.getProfile(user.parentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
if (available.lt(amt)) {
|
||||
throw new BadRequestException('超过玩家上级代理可用授信,无法上分');
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
|
||||
async adminDepositToPlayer(
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
const result = await this.wallet.deposit(
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark,
|
||||
requestId,
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
|
||||
async getPlayerTransferContext(
|
||||
playerId: bigint,
|
||||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||||
) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player) throw new NotFoundException('玩家不存在');
|
||||
|
||||
if (options.actingAgentId) {
|
||||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||||
}
|
||||
|
||||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||||
|
||||
let credit: Record<string, unknown> | null = null;
|
||||
if (creditAgentId) {
|
||||
await this.recalculateUsedCredit(creditAgentId);
|
||||
const profile = await this.getProfile(creditAgentId);
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
let dailyDepositUsed: string | null = null;
|
||||
if (!options.forAdmin) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||||
}
|
||||
|
||||
const agentUser = await this.prisma.user.findUnique({
|
||||
where: { id: creditAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
credit = {
|
||||
agentId: creditAgentId.toString(),
|
||||
agentUsername: agentUser?.username ?? '',
|
||||
agentLevel: profile.level,
|
||||
creditLimit: dec(profile.creditLimit),
|
||||
usedCredit: dec(profile.usedCredit),
|
||||
availableCredit: dec(profile.availableCredit),
|
||||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||||
dailyDepositUsed,
|
||||
appliesDepositLimits: !options.forAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id.toString(),
|
||||
username: player.username,
|
||||
availableBalance: dec(player.wallet?.availableBalance),
|
||||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||||
},
|
||||
credit,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: creditAgentId },
|
||||
});
|
||||
if (!profile) return;
|
||||
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: profile.parentAgentId },
|
||||
})
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
|
||||
throw new BadRequestException('超过代理单笔上分限额');
|
||||
}
|
||||
|
||||
if (maxDailyDeposit) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||||
if (dailyTotal.gt(maxDailyDeposit)) {
|
||||
throw new BadRequestException('超过代理日上分限额');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertChildCreditWithinParent(
|
||||
parentAgentId: bigint,
|
||||
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
|
||||
creditAfter: Decimal,
|
||||
) {
|
||||
await this.assertChildAgentWithinParent(parentAgentId, { creditLimit: creditAfter });
|
||||
|
||||
const parent = await this.getProfile(parentAgentId);
|
||||
const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
|
||||
const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
|
||||
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
|
||||
const exposureDelta = newExposure.sub(oldExposure);
|
||||
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
|
||||
throw new BadRequestException('上级可用授信不足');
|
||||
}
|
||||
}
|
||||
|
||||
async depositToPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
|
||||
if (!player || player.parentId !== agentId) {
|
||||
throw new ForbiddenException('Can only deposit to direct players');
|
||||
}
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
const profile = await this.getProfile(agentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
@@ -130,7 +404,9 @@ export class AgentsService {
|
||||
throw new BadRequestException('Insufficient agent credit');
|
||||
}
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, 'Agent deposit', requestId);
|
||||
await this.assertAgentDepositLimits(agentId, amt);
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, remark ?? 'Agent deposit', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
@@ -142,10 +418,7 @@ export class AgentsService {
|
||||
amount: number,
|
||||
requestId: string,
|
||||
) {
|
||||
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
|
||||
if (!player || player.parentId !== agentId) {
|
||||
throw new ForbiddenException('Can only withdraw from direct players');
|
||||
}
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
@@ -153,16 +426,124 @@ export class AgentsService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
|
||||
const user = await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
const [betCount, betStake] = await Promise.all([
|
||||
this.prisma.bet.count({ where: { userId: playerId } }),
|
||||
this.prisma.bet.aggregate({
|
||||
where: { userId: playerId },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
status: user.status,
|
||||
phone: user.preferences?.phone ?? null,
|
||||
email: user.preferences?.email ?? null,
|
||||
managedPassword: user.preferences?.managedPassword ?? null,
|
||||
availableBalance: user.wallet?.availableBalance?.toString() ?? '0',
|
||||
frozenBalance: user.wallet?.frozenBalance?.toString() ?? '0',
|
||||
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
||||
loginFailCount: user.auth?.loginFailCount ?? 0,
|
||||
betCount,
|
||||
totalStake: betStake._sum.stake?.toString() ?? '0',
|
||||
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDirectPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
data: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
const user = await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
if (nextUsername !== user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.password !== undefined) {
|
||||
const nextPassword = data.password;
|
||||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||||
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
|
||||
const hash = await bcrypt.hash(nextPassword, 10);
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId: playerId },
|
||||
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||||
});
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: { userId: playerId, managedPassword: nextPassword },
|
||||
update: { managedPassword: nextPassword },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { status: data.status },
|
||||
});
|
||||
}
|
||||
|
||||
const prefPatch: { phone?: string | null; email?: string | null } = {};
|
||||
if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
|
||||
if (data.email !== undefined) prefPatch.email = data.email.trim() || null;
|
||||
|
||||
if (Object.keys(prefPatch).length > 0) {
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: {
|
||||
userId: playerId,
|
||||
phone: prefPatch.phone ?? null,
|
||||
email: prefPatch.email ?? null,
|
||||
},
|
||||
update: prefPatch,
|
||||
});
|
||||
}
|
||||
|
||||
return this.getDirectPlayerDetail(agentId, playerId);
|
||||
}
|
||||
|
||||
async listAgentsAdmin(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
parentAgentId?: bigint;
|
||||
}) {
|
||||
const page = Math.max(1, params?.page ?? 1);
|
||||
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AgentProfileWhereInput = {};
|
||||
if (params?.parentAgentId !== undefined) {
|
||||
where.parentAgentId = params.parentAgentId;
|
||||
} else {
|
||||
// Default: only show top-level agents (no parent)
|
||||
where.parentAgentId = null;
|
||||
}
|
||||
const kw = params?.keyword?.trim();
|
||||
if (kw) {
|
||||
where.user = { username: { contains: kw, mode: 'insensitive' } };
|
||||
@@ -199,6 +580,18 @@ export class AgentsService {
|
||||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
const childAgentCounts =
|
||||
agentIds.length > 0
|
||||
? await this.prisma.agentProfile.groupBy({
|
||||
by: ['parentAgentId'],
|
||||
where: { parentAgentId: { in: agentIds } },
|
||||
_count: { _all: true },
|
||||
})
|
||||
: [];
|
||||
const childAgentCountMap = new Map(
|
||||
childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
const items = profiles.map((p) => {
|
||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||
return {
|
||||
@@ -215,7 +608,10 @@ export class AgentsService {
|
||||
directPlayerLiability: p.directPlayerLiability.toString(),
|
||||
childAgentExposure: p.childAgentExposure.toString(),
|
||||
cashbackRate: p.cashbackRate.toString(),
|
||||
maxSingleDeposit: p.maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: p.maxDailyDeposit?.toString() ?? null,
|
||||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||||
childAgentCount: childAgentCountMap.get(p.userId.toString()) ?? 0,
|
||||
phone: p.user.preferences?.phone ?? null,
|
||||
email: p.user.preferences?.email ?? null,
|
||||
locale: p.user.locale,
|
||||
@@ -234,10 +630,13 @@ export class AgentsService {
|
||||
});
|
||||
if (!profile) throw new NotFoundException('代理不存在');
|
||||
|
||||
const [directPlayerCount, recentCredits] = await Promise.all([
|
||||
const [directPlayerCount, childAgentCount, recentCredits] = await Promise.all([
|
||||
this.prisma.user.count({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
}),
|
||||
this.prisma.agentProfile.count({
|
||||
where: { parentAgentId: agentId },
|
||||
}),
|
||||
this.prisma.agentCreditTransaction.findMany({
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -270,11 +669,16 @@ export class AgentsService {
|
||||
directPlayerLiability: profile.directPlayerLiability.toString(),
|
||||
childAgentExposure: profile.childAgentExposure.toString(),
|
||||
cashbackRate: profile.cashbackRate.toString(),
|
||||
maxSingleDeposit: profile.maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: profile.maxDailyDeposit?.toString() ?? null,
|
||||
directPlayerCount,
|
||||
childAgentCount,
|
||||
phone: profile.user.preferences?.phone ?? null,
|
||||
email: profile.user.preferences?.email ?? null,
|
||||
managedPassword: profile.user.preferences?.managedPassword ?? null,
|
||||
locale: profile.user.locale,
|
||||
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
|
||||
loginFailCount: profile.user.auth?.loginFailCount ?? 0,
|
||||
createdAt: profile.createdAt,
|
||||
updatedAt: profile.updatedAt,
|
||||
recentCreditTransactions: recentCredits.map((t) => ({
|
||||
@@ -297,6 +701,11 @@ export class AgentsService {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
cashbackRate?: number;
|
||||
maxSingleDeposit?: number | null;
|
||||
maxDailyDeposit?: number | null;
|
||||
username?: string;
|
||||
password?: string;
|
||||
freezeDirectPlayers?: boolean;
|
||||
},
|
||||
) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
@@ -309,6 +718,38 @@ export class AgentsService {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
// Handle username change
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
if (nextUsername !== profile.user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: agentId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle password change
|
||||
if (data.password !== undefined) {
|
||||
const nextPassword = data.password;
|
||||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||||
const hash = await bcrypt.hash(nextPassword, 10);
|
||||
await this.prisma.userAuth.upsert({
|
||||
where: { userId: agentId },
|
||||
create: { userId: agentId, passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||||
update: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||||
});
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: agentId },
|
||||
create: { userId: agentId, managedPassword: nextPassword },
|
||||
update: { managedPassword: nextPassword },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle status change (with optional cascade freeze)
|
||||
if (data.status) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
@@ -320,6 +761,19 @@ export class AgentsService {
|
||||
data: { status: data.status },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 级联冻结:需后台开启且管理员/操作方显式勾选(MVP 默认不冻结玩家)
|
||||
const suspendSettings = await this.systemConfig.getAgentSuspendSettings();
|
||||
if (
|
||||
data.status === 'SUSPENDED' &&
|
||||
data.freezeDirectPlayers &&
|
||||
suspendSettings.suspendFreezeDirectPlayers
|
||||
) {
|
||||
await this.prisma.user.updateMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
data: { status: 'SUSPENDED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
@@ -330,12 +784,40 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
if (data.cashbackRate !== undefined) {
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildAgentWithinParent(profile.parentAgentId, {
|
||||
cashbackRate: data.cashbackRate,
|
||||
});
|
||||
}
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { cashbackRate: data.cashbackRate },
|
||||
});
|
||||
}
|
||||
|
||||
const limitPatch: {
|
||||
maxSingleDeposit?: Decimal | null;
|
||||
maxDailyDeposit?: Decimal | null;
|
||||
} = {};
|
||||
if (data.maxSingleDeposit !== undefined) {
|
||||
limitPatch.maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
|
||||
}
|
||||
if (data.maxDailyDeposit !== undefined) {
|
||||
limitPatch.maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
|
||||
}
|
||||
if (Object.keys(limitPatch).length > 0) {
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildAgentWithinParent(profile.parentAgentId, {
|
||||
maxSingleDeposit: limitPatch.maxSingleDeposit ?? undefined,
|
||||
maxDailyDeposit: limitPatch.maxDailyDeposit ?? undefined,
|
||||
});
|
||||
}
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: limitPatch,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||||
@@ -389,6 +871,8 @@ export class AgentsService {
|
||||
data: {
|
||||
creditLimit: number;
|
||||
cashbackRate?: number;
|
||||
maxSingleDeposit?: number | null;
|
||||
maxDailyDeposit?: number | null;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
},
|
||||
@@ -450,6 +934,8 @@ export class AgentsService {
|
||||
parentAgentId: null,
|
||||
creditLimit: data.creditLimit,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
maxSingleDeposit: this.normalizeOptionalLimit(data.maxSingleDeposit),
|
||||
maxDailyDeposit: this.normalizeOptionalLimit(data.maxDailyDeposit),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -481,6 +967,8 @@ export class AgentsService {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
cashbackRate?: number;
|
||||
maxSingleDeposit?: number | null;
|
||||
maxDailyDeposit?: number | null;
|
||||
},
|
||||
) {
|
||||
if (data.level !== 1 && data.level !== 2) {
|
||||
@@ -490,6 +978,18 @@ export class AgentsService {
|
||||
throw new BadRequestException('Level 2 agent requires parent');
|
||||
}
|
||||
|
||||
if (data.parentAgentId) {
|
||||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
maxSingleDeposit: data.maxSingleDeposit,
|
||||
maxDailyDeposit: data.maxDailyDeposit,
|
||||
});
|
||||
}
|
||||
|
||||
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
|
||||
const maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
|
||||
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
@@ -524,6 +1024,8 @@ export class AgentsService {
|
||||
parentAgentId: data.parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
maxSingleDeposit,
|
||||
maxDailyDeposit,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -567,8 +1069,12 @@ export class AgentsService {
|
||||
depositRemark?: string;
|
||||
depositRequestId?: string;
|
||||
asTier1Agent?: boolean;
|
||||
asSubAgent?: boolean;
|
||||
parentAgentId?: bigint;
|
||||
creditLimit?: number;
|
||||
cashbackRate?: number;
|
||||
maxSingleDeposit?: number | null;
|
||||
maxDailyDeposit?: number | null;
|
||||
},
|
||||
) {
|
||||
if (data.asTier1Agent) {
|
||||
@@ -590,6 +1096,29 @@ export class AgentsService {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.asSubAgent) {
|
||||
if (data.parentAgentId == null && data.parentId == null) {
|
||||
throw new BadRequestException('二级代理必须指定上级代理');
|
||||
}
|
||||
if (data.initialDeposit && data.initialDeposit > 0) {
|
||||
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
|
||||
}
|
||||
const parentAgentId = data.parentAgentId ?? data.parentId;
|
||||
return this.createAgent(operatorId, {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
level: 2,
|
||||
parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
maxSingleDeposit: data.maxSingleDeposit,
|
||||
maxDailyDeposit: data.maxDailyDeposit,
|
||||
locale: data.locale,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
});
|
||||
}
|
||||
|
||||
let parentId: bigint | null = null;
|
||||
if (data.parentId != null) {
|
||||
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
|
||||
@@ -597,6 +1126,11 @@ export class AgentsService {
|
||||
throw new BadRequestException('上级必须为代理账号');
|
||||
}
|
||||
parentId = data.parentId;
|
||||
|
||||
const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
|
||||
if (operator?.userType === 'AGENT' && parentId !== operatorId) {
|
||||
throw new ForbiddenException('Can only create direct players');
|
||||
}
|
||||
}
|
||||
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
@@ -641,6 +1175,7 @@ export class AgentsService {
|
||||
if (initial > 0) {
|
||||
const requestId =
|
||||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||||
await this.assertPlayerParentCreditForDeposit(user.id, initial);
|
||||
await this.wallet.deposit(
|
||||
user.id,
|
||||
initial,
|
||||
@@ -660,6 +1195,7 @@ export class AgentsService {
|
||||
return this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -670,34 +1206,253 @@ export class AgentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listChildAgentsSummary(parentAgentId: bigint) {
|
||||
const profiles = await this.getChildAgents(parentAgentId);
|
||||
const agentIds = profiles.map((p) => p.userId);
|
||||
const playerCounts =
|
||||
agentIds.length > 0
|
||||
? await this.prisma.user.groupBy({
|
||||
by: ['parentId'],
|
||||
where: {
|
||||
userType: 'PLAYER',
|
||||
parentId: { in: agentIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const countMap = new Map(
|
||||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
return profiles.map((p) => {
|
||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||
return {
|
||||
userId: p.userId.toString(),
|
||||
username: p.user.username,
|
||||
userStatus: p.user.status,
|
||||
status: p.status,
|
||||
level: p.level,
|
||||
creditLimit: dec(p.creditLimit),
|
||||
usedCredit: dec(p.usedCredit),
|
||||
availableCredit: available.toString(),
|
||||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: subAgentId },
|
||||
});
|
||||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||||
throw new ForbiddenException('Not your sub-agent');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||
return this.getAgentAdminDetail(subAgentId);
|
||||
}
|
||||
|
||||
async updateSubAgentForParent(
|
||||
parentAgentId: bigint,
|
||||
subAgentId: bigint,
|
||||
data: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
freezeDirectPlayers?: boolean;
|
||||
},
|
||||
) {
|
||||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||
const { freezeDirectPlayers: _ignored, ...safeData } = data;
|
||||
return this.updateAgentAdmin(subAgentId, safeData);
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const descendants = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
select: { descendantId: true },
|
||||
});
|
||||
return descendants.map((d) => d.descendantId);
|
||||
}
|
||||
|
||||
async getReportSummary(agentId: bigint) {
|
||||
const profile = await this.getProfile(agentId);
|
||||
const players = await this.getDirectPlayers(agentId);
|
||||
const agentIds = await this.getSubtreeAgentIds(agentId);
|
||||
const betScope = { agentId: { in: agentIds } };
|
||||
const playerWhere = {
|
||||
parentId: agentId,
|
||||
userType: 'PLAYER' as const,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const todayBets = await this.prisma.bet.aggregate({
|
||||
where: {
|
||||
agentId,
|
||||
placedAt: { gte: today },
|
||||
},
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
});
|
||||
const trend7d = await Promise.all(
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const dayStart = new Date(today);
|
||||
dayStart.setDate(dayStart.getDate() - (6 - i));
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
return this.prisma.bet
|
||||
.aggregate({
|
||||
where: { ...betScope, placedAt: { gte: dayStart, lt: dayEnd } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
})
|
||||
.then((agg) => ({
|
||||
date: dayStart.toISOString().slice(0, 10),
|
||||
label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
|
||||
betCount: agg._count,
|
||||
stake: dec(agg._sum.stake),
|
||||
payout: dec(agg._sum.actualReturn),
|
||||
ggr: sub(agg._sum.stake, agg._sum.actualReturn),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const [
|
||||
todayBets,
|
||||
yesterdayBets,
|
||||
pendingBets,
|
||||
betStatusToday,
|
||||
playerTotal,
|
||||
playerActive,
|
||||
playerSuspended,
|
||||
newPlayersToday,
|
||||
subAgentTotal,
|
||||
subAgentsActive,
|
||||
walletAgg,
|
||||
recentBets,
|
||||
recentPlayers,
|
||||
] = await Promise.all([
|
||||
this.prisma.bet.aggregate({
|
||||
where: { ...betScope, placedAt: { gte: today } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.bet.aggregate({
|
||||
where: { ...betScope, placedAt: { gte: yesterday, lt: today } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.bet.count({ where: { ...betScope, status: 'PENDING' } }),
|
||||
this.prisma.bet.groupBy({
|
||||
by: ['status'],
|
||||
where: { ...betScope, placedAt: { gte: today } },
|
||||
_count: { _all: true },
|
||||
_sum: { stake: true },
|
||||
}),
|
||||
this.prisma.user.count({ where: playerWhere }),
|
||||
this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
|
||||
this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
|
||||
this.prisma.user.count({
|
||||
where: { ...playerWhere, createdAt: { gte: today } },
|
||||
}),
|
||||
this.prisma.agentProfile.count({ where: { parentAgentId: agentId } }),
|
||||
this.prisma.agentProfile.count({
|
||||
where: { parentAgentId: agentId, status: 'ACTIVE' },
|
||||
}),
|
||||
this.prisma.wallet.aggregate({
|
||||
where: { user: playerWhere },
|
||||
_sum: { availableBalance: true, frozenBalance: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bet.findMany({
|
||||
where: betScope,
|
||||
take: 8,
|
||||
orderBy: { placedAt: 'desc' },
|
||||
include: { user: { select: { username: true } } },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: playerWhere,
|
||||
take: 6,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
|
||||
for (const g of betStatusToday) {
|
||||
todayBetByStatus[g.status] = {
|
||||
count: g._count._all,
|
||||
stake: dec(g._sum.stake),
|
||||
};
|
||||
}
|
||||
|
||||
const creditLimit = profile.creditLimit ?? new Decimal(0);
|
||||
const usedCredit = profile.usedCredit ?? new Decimal(0);
|
||||
const availableCredit = new Decimal(creditLimit).sub(usedCredit);
|
||||
|
||||
return {
|
||||
profile,
|
||||
directPlayerCount: players.length,
|
||||
directPlayerTotalBalance: players.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Number(p.wallet?.availableBalance ?? 0) +
|
||||
Number(p.wallet?.frozenBalance ?? 0),
|
||||
0,
|
||||
),
|
||||
todayBetCount: todayBets._count,
|
||||
todayStake: todayBets._sum.stake,
|
||||
todayReturn: todayBets._sum.actualReturn,
|
||||
generatedAt: new Date().toISOString(),
|
||||
trend7d,
|
||||
today: {
|
||||
betCount: todayBets._count,
|
||||
stake: dec(todayBets._sum.stake),
|
||||
payout: dec(todayBets._sum.actualReturn),
|
||||
ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
|
||||
newPlayers: newPlayersToday,
|
||||
},
|
||||
yesterday: {
|
||||
betCount: yesterdayBets._count,
|
||||
stake: dec(yesterdayBets._sum.stake),
|
||||
payout: dec(yesterdayBets._sum.actualReturn),
|
||||
ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
|
||||
},
|
||||
players: {
|
||||
directTotal: playerTotal,
|
||||
active: playerActive,
|
||||
suspended: playerSuspended,
|
||||
newToday: newPlayersToday,
|
||||
},
|
||||
subAgents: {
|
||||
total: subAgentTotal,
|
||||
active: subAgentsActive,
|
||||
},
|
||||
wallets: {
|
||||
totalAvailable: dec(walletAgg._sum.availableBalance),
|
||||
totalFrozen: dec(walletAgg._sum.frozenBalance),
|
||||
playerWalletCount: walletAgg._count._all,
|
||||
},
|
||||
credit: {
|
||||
creditLimit: dec(creditLimit),
|
||||
usedCredit: dec(usedCredit),
|
||||
availableCredit: availableCredit.toString(),
|
||||
directPlayerLiability: dec(profile.directPlayerLiability),
|
||||
childAgentExposure: dec(profile.childAgentExposure),
|
||||
},
|
||||
bets: {
|
||||
pendingTotal: pendingBets,
|
||||
todayByStatus: todayBetByStatus,
|
||||
},
|
||||
recentBets: recentBets.map((b) => ({
|
||||
betNo: b.betNo,
|
||||
username: b.user.username,
|
||||
stake: dec(b.stake),
|
||||
status: b.status,
|
||||
placedAt: b.placedAt,
|
||||
})),
|
||||
recentPlayers: recentPlayers.map((p) => ({
|
||||
id: p.id.toString(),
|
||||
username: p.username,
|
||||
status: p.status,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, ChangePasswordDto } from './auth.dto';
|
||||
@@ -39,6 +39,27 @@ export class AuthController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('manage/auth/me')
|
||||
async manageMe(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('username') username: string,
|
||||
@CurrentUser('userType') userType: string,
|
||||
@CurrentUser('locale') locale: string | undefined,
|
||||
@CurrentUser('role') role: string | undefined,
|
||||
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
|
||||
) {
|
||||
return jsonResponse({
|
||||
id: userId.toString(),
|
||||
username,
|
||||
userType,
|
||||
locale,
|
||||
role,
|
||||
agentLevel: userType === 'AGENT' ? agentLevel ?? null : null,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('player/auth/change-password')
|
||||
|
||||
@@ -56,6 +56,23 @@ export class AuthService {
|
||||
throw new ForbiddenException('Account disabled');
|
||||
}
|
||||
|
||||
if (portal === 'agent' && user.status === 'SUSPENDED') {
|
||||
throw new ForbiddenException('Agent account suspended');
|
||||
}
|
||||
|
||||
if (portal === 'player' && user.parentId) {
|
||||
const agentSettings = await this.systemConfig.getAgentSuspendSettings();
|
||||
if (agentSettings.suspendBlockPlayerLogin) {
|
||||
const parentAgent = await this.prisma.user.findUnique({
|
||||
where: { id: user.parentId },
|
||||
select: { userType: true, status: true },
|
||||
});
|
||||
if (parentAgent?.userType === 'AGENT' && parentAgent.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('上级代理已停用,暂无法登录');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
|
||||
throw new ForbiddenException('Account locked, try again later');
|
||||
}
|
||||
@@ -101,6 +118,7 @@ export class AuthService {
|
||||
userType: user.userType,
|
||||
locale: user.locale,
|
||||
role: user.adminRole?.role?.code,
|
||||
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,16 +281,29 @@ export class WalletService {
|
||||
};
|
||||
}
|
||||
|
||||
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
||||
async getTransactions(userId: bigint, page = 1, pageSize = 20, typeFilter?: string) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let typeWhere: Record<string, unknown> = {};
|
||||
if (typeFilter === 'deposit') {
|
||||
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
|
||||
} else if (typeFilter === 'withdraw') {
|
||||
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
|
||||
} else if (typeFilter === 'bet') {
|
||||
typeWhere = { transactionType: { in: ['BET_FREEZE', 'BET_DEDUCT', 'BET_SETTLE_WIN', 'BET_SETTLE_LOSE', 'BET_SETTLE_PUSH', 'BET_WIN', 'BET_REFUND', 'BET_VOID', 'BET_VOID_REFUND', 'RESETTLE_REVERSE'] } };
|
||||
} else if (typeFilter === 'cashback') {
|
||||
typeWhere = { transactionType: { in: ['CASHBACK', 'CASHBACK_DEPOSIT'] } };
|
||||
}
|
||||
|
||||
const where = { userId, ...typeWhere };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.walletTransaction.findMany({
|
||||
where: { userId },
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.walletTransaction.count({ where: { userId } }),
|
||||
this.prisma.walletTransaction.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ describe('Agent Credit Rules', () => {
|
||||
const canDeposit = agentId === playerParentId;
|
||||
expect(canDeposit).toBe(false);
|
||||
});
|
||||
|
||||
it('A008: admin deposit cannot exceed parent agent available credit', () => {
|
||||
const creditLimit = 5000;
|
||||
const usedCredit = 4800;
|
||||
const depositAmount = 300;
|
||||
const available = creditLimit - usedCredit;
|
||||
expect(depositAmount).toBeGreaterThan(available);
|
||||
const allowed = depositAmount <= available;
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bet Validation Rules (B001-B010)', () => {
|
||||
|
||||
@@ -3,12 +3,21 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
|
||||
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change';
|
||||
export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_players';
|
||||
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
|
||||
|
||||
export type PlayerAccountSettings = {
|
||||
allowPasswordChange: boolean;
|
||||
allowUsernameChange: boolean;
|
||||
};
|
||||
|
||||
export type AgentSuspendSettings = {
|
||||
/** 停用代理时是否允许级联冻结其直属玩家(需管理员显式勾选) */
|
||||
suspendFreezeDirectPlayers: boolean;
|
||||
/** 上级代理停用时是否禁止其直属玩家登录 */
|
||||
suspendBlockPlayerLogin: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -56,4 +65,30 @@ export class SystemConfigService {
|
||||
}
|
||||
return this.getPlayerAccountSettings();
|
||||
}
|
||||
|
||||
async getAgentSuspendSettings(): Promise<AgentSuspendSettings> {
|
||||
const [suspendFreezeDirectPlayers, suspendBlockPlayerLogin] = await Promise.all([
|
||||
this.getBoolean(AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS, false),
|
||||
this.getBoolean(AGENT_SUSPEND_BLOCK_PLAYER_LOGIN, false),
|
||||
]);
|
||||
return { suspendFreezeDirectPlayers, suspendBlockPlayerLogin };
|
||||
}
|
||||
|
||||
async updateAgentSuspendSettings(data: Partial<AgentSuspendSettings>) {
|
||||
if (data.suspendFreezeDirectPlayers !== undefined) {
|
||||
await this.setBoolean(
|
||||
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
|
||||
data.suspendFreezeDirectPlayers,
|
||||
'停用代理时是否允许级联冻结直属玩家',
|
||||
);
|
||||
}
|
||||
if (data.suspendBlockPlayerLogin !== undefined) {
|
||||
await this.setBoolean(
|
||||
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
|
||||
data.suspendBlockPlayerLogin,
|
||||
'上级代理停用时是否禁止直属玩家登录',
|
||||
);
|
||||
}
|
||||
return this.getAgentSuspendSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
const url: string = err.config?.url ?? '';
|
||||
// Don't redirect on login/auth failures — let the caller handle the error
|
||||
if (!url.includes('/auth/login')) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -10,22 +10,39 @@ interface Transaction {
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ items: Transaction[] }>();
|
||||
const props = withDefaults(defineProps<{
|
||||
items: Transaction[];
|
||||
cashbackTotal?: string;
|
||||
}>(), {
|
||||
cashbackTotal: '0',
|
||||
});
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
|
||||
|
||||
const stats = computed(() => {
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
let cashback = 0;
|
||||
|
||||
for (const tx of props.items) {
|
||||
const amt = parseAmount(tx.amount);
|
||||
const isCb = CASHBACK_TYPES.has(tx.transactionType.toUpperCase());
|
||||
if (isCb) {
|
||||
cashback += Math.abs(amt);
|
||||
}
|
||||
if (amt >= 0) income += amt;
|
||||
else expense += Math.abs(amt);
|
||||
}
|
||||
|
||||
// Use server-side cashback total if provided (more accurate across all pages)
|
||||
if (props.cashbackTotal !== '0') {
|
||||
cashback = Math.abs(parseAmount(props.cashbackTotal));
|
||||
}
|
||||
|
||||
const net = income - expense;
|
||||
|
||||
return { income, expense, net };
|
||||
return { income, expense, net, cashback };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -48,6 +65,11 @@ const stats = computed(() => {
|
||||
</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val cashback">{{ formatMoney(stats.cashback, locale) }}</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,6 +114,7 @@ const stats = computed(() => {
|
||||
|
||||
.stat-val.income { color: #3db865; }
|
||||
.stat-val.expense { color: #e05050; }
|
||||
.stat-val.cashback { color: #f0b90b; }
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
|
||||
@@ -23,7 +23,13 @@ const slip = useBetSlipStore();
|
||||
|
||||
const isDetailPage = computed(() => {
|
||||
const p = route.path;
|
||||
return p.startsWith('/match/') || p.startsWith('/bet/') || p.startsWith('/bets/');
|
||||
return (
|
||||
p.startsWith('/match/') ||
|
||||
p.startsWith('/bet/') ||
|
||||
p.startsWith('/bets/') ||
|
||||
p.startsWith('/wallet/') ||
|
||||
p === '/profile/edit'
|
||||
);
|
||||
});
|
||||
|
||||
const showHeader = computed(() => !isDetailPage.value);
|
||||
|
||||
@@ -97,10 +97,13 @@ const i18n = createI18n({
|
||||
stats_income: '收入',
|
||||
stats_expense: '支出',
|
||||
stats_net: '净额',
|
||||
stats_cashback: '反水',
|
||||
filter_all: '全部',
|
||||
filter_deposit: '存款',
|
||||
filter_withdraw: '提款',
|
||||
filter_bet: '投注',
|
||||
filter_cashback: '反水',
|
||||
view_all: '查看全部账单',
|
||||
detail_summary: '账务明细',
|
||||
detail_amount: '变动金额',
|
||||
detail_balance_before: '变动前余额',
|
||||
@@ -378,10 +381,13 @@ const i18n = createI18n({
|
||||
stats_income: 'Income',
|
||||
stats_expense: 'Expense',
|
||||
stats_net: 'Net',
|
||||
stats_cashback: 'Cashback',
|
||||
filter_all: 'All',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Withdraw',
|
||||
filter_bet: 'Bet',
|
||||
filter_cashback: 'Cashback',
|
||||
view_all: 'View all transactions',
|
||||
detail_summary: 'Details',
|
||||
detail_amount: 'Amount',
|
||||
detail_balance_before: 'Balance Before',
|
||||
@@ -665,10 +671,13 @@ const i18n = createI18n({
|
||||
stats_income: 'Pendapatan',
|
||||
stats_expense: 'Perbelanjaan',
|
||||
stats_net: 'Bersih',
|
||||
stats_cashback: 'Rebat',
|
||||
filter_all: 'Semua',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Pengeluaran',
|
||||
filter_bet: 'Pertaruhan',
|
||||
filter_cashback: 'Rebat',
|
||||
view_all: 'Lihat semua transaksi',
|
||||
detail_summary: 'Butiran',
|
||||
detail_amount: 'Jumlah',
|
||||
detail_balance_before: 'Baki Sebelum',
|
||||
|
||||
@@ -17,6 +17,7 @@ const router = createRouter({
|
||||
{ path: 'bets', component: () => import('../views/MyBetsView.vue') },
|
||||
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue') },
|
||||
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue') },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue') },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
|
||||
|
||||
@@ -24,7 +24,7 @@ export function txTypeKey(type: string): string {
|
||||
|
||||
export function isDepositType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT';
|
||||
return (t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT') && !isCashbackType(type);
|
||||
}
|
||||
|
||||
export function isWithdrawType(type: string): boolean {
|
||||
@@ -34,3 +34,8 @@ export function isWithdrawType(type: string): boolean {
|
||||
export function isBetType(type: string): boolean {
|
||||
return type.toUpperCase().startsWith('BET_') || type.toUpperCase() === 'RESETTLE_REVERSE';
|
||||
}
|
||||
|
||||
export function isCashbackType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t === 'CASHBACK' || t === 'CASHBACK_DEPOSIT';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import type { BetHistoryItem } from '../components/BetHistoryCard.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -15,7 +16,8 @@ const bet = ref<BetHistoryItem | null>(null);
|
||||
const loading = ref(true);
|
||||
const notFound = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadBet() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/player/bets/${route.params.betNo}`);
|
||||
if (!data.data) { notFound.value = true; return; }
|
||||
@@ -25,6 +27,17 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadBet);
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: loadBet,
|
||||
});
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
const statusKey = computed(() => {
|
||||
@@ -113,6 +126,13 @@ const myPick = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<!-- back bar -->
|
||||
<div class="top-bar">
|
||||
<button class="back-btn" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||
@@ -226,6 +246,14 @@ const myPick = computed(() => {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import CorrectScoreConfirmModal, {
|
||||
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
|
||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import BetSuccessOverlay from '../components/BetSuccessOverlay.vue';
|
||||
@@ -213,6 +214,15 @@ async function loadMatch() {
|
||||
|
||||
useOnLocaleChange(loadMatch);
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: loadMatch,
|
||||
});
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
function isSelected(id: string) {
|
||||
return slip.items.some((i) => i.selectionId === id);
|
||||
}
|
||||
@@ -257,6 +267,13 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<header class="toolbar">
|
||||
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
||||
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
|
||||
@@ -407,6 +424,14 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -59,9 +61,20 @@ function togglePasswordVisible() {
|
||||
passwordVisible.value = !passwordVisible.value;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadEditData() {
|
||||
await loadProfile(true);
|
||||
syncFromProfile();
|
||||
}
|
||||
|
||||
onMounted(loadEditData);
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: loadEditData,
|
||||
});
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
function openAvatarModal() {
|
||||
@@ -165,6 +178,13 @@ function back() {
|
||||
|
||||
<template>
|
||||
<div class="edit-page">
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<header class="page-head">
|
||||
<button type="button" class="back-btn" @click="back">← {{ t('profile.back') }}</button>
|
||||
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
||||
@@ -294,6 +314,14 @@ function back() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ function logout() {
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div class="wallet-banner">
|
||||
<div class="wallet-banner" @click="router.push('/wallet/detail')">
|
||||
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
||||
<img src="/logo.png" alt="TheBet365" class="wallet-card-logo" />
|
||||
<div class="wallet-banner-info">
|
||||
@@ -115,6 +115,7 @@ function logout() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="wallet-chevron" aria-hidden="true">›</span>
|
||||
</div>
|
||||
|
||||
<section class="settings-group">
|
||||
@@ -184,7 +185,36 @@ function logout() {
|
||||
.wallet-banner {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
margin-left: -5px;
|
||||
margin-right: -5px;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wallet-banner:active .wallet-banner-img {
|
||||
filter: brightness(0.9);
|
||||
transition: filter 0.1s;
|
||||
}
|
||||
|
||||
.wallet-chevron {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: 12%;
|
||||
z-index: 3;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.wallet-banner-img {
|
||||
|
||||
357
apps/player/src/views/WalletDetailView.vue
Normal file
357
apps/player/src/views/WalletDetailView.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
type Transaction = {
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
createdAt: string;
|
||||
transactionId: string;
|
||||
};
|
||||
|
||||
const items = ref<Transaction[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const loading = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const hasMore = ref(true);
|
||||
const typeFilter = ref('');
|
||||
const cashbackTotal = ref('0');
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const FILTERS = [
|
||||
{ key: '', label: 'wallet.filter_all' },
|
||||
{ key: 'deposit', label: 'wallet.filter_deposit' },
|
||||
{ key: 'withdraw', label: 'wallet.filter_withdraw' },
|
||||
{ key: 'bet', label: 'wallet.filter_bet' },
|
||||
{ key: 'cashback', label: 'wallet.filter_cashback' },
|
||||
];
|
||||
|
||||
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
|
||||
|
||||
async function fetchCashbackTotal() {
|
||||
try {
|
||||
const { data } = await api.get('/player/wallet/transactions/stats');
|
||||
const byType = data.data?.byType ?? [];
|
||||
let sum = 0;
|
||||
for (const g of byType) {
|
||||
if (CASHBACK_TYPES.has(g.transactionType?.toUpperCase())) {
|
||||
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
|
||||
}
|
||||
}
|
||||
cashbackTotal.value = sum.toString();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function txLabel(type: string): string {
|
||||
const key = txTypeKey(type);
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function goDetail(tx: Transaction) {
|
||||
if (!tx.transactionId) return;
|
||||
router.push(`/wallet/transactions/${tx.transactionId}`);
|
||||
}
|
||||
|
||||
async function loadPage(p: number) {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const filterAtRequest = typeFilter.value;
|
||||
try {
|
||||
const params: Record<string, unknown> = { page: p };
|
||||
if (typeFilter.value) params.type = typeFilter.value;
|
||||
const { data } = await api.get('/player/wallet/transactions', { params });
|
||||
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
|
||||
if (filterAtRequest !== typeFilter.value) return;
|
||||
total.value = result.total ?? 0;
|
||||
const pageSize = result.pageSize ?? 20;
|
||||
const newItems = result.items ?? [];
|
||||
if (p === 1) {
|
||||
items.value = newItems;
|
||||
} else {
|
||||
items.value = [...items.value, ...newItems];
|
||||
}
|
||||
hasMore.value = (result.items?.length ?? 0) >= pageSize;
|
||||
page.value = p;
|
||||
} finally {
|
||||
if (filterAtRequest === typeFilter.value) {
|
||||
loading.value = false;
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
initialLoading.value = true;
|
||||
loading.value = false;
|
||||
loadPage(1);
|
||||
}
|
||||
|
||||
function changeFilter(key: string) {
|
||||
if (typeFilter.value === key) return;
|
||||
typeFilter.value = key;
|
||||
reset();
|
||||
}
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: async () => { await loadPage(1); await fetchCashbackTotal(); },
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(1);
|
||||
fetchCashbackTotal();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
|
||||
loadPage(page.value + 1);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
if (sentinel.value) observer.observe(sentinel.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wallet-detail-page">
|
||||
<div class="top-bar">
|
||||
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div v-if="initialLoading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
<span class="loading-text">{{ t('bet.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<WalletStatsPanel :items="items" :cashback-total="cashbackTotal" />
|
||||
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="f in FILTERS"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="filter-tab"
|
||||
:class="{ active: typeFilter === f.key }"
|
||||
@click="changeFilter(f.key)"
|
||||
>
|
||||
{{ t(f.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="tx-list">
|
||||
<button
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId"
|
||||
type="button"
|
||||
class="tx-row"
|
||||
@click="goDetail(tx)"
|
||||
>
|
||||
<div class="tx-main">
|
||||
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tx-meta">
|
||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||
<span class="tx-arrow">›</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="sentinel" />
|
||||
|
||||
<div v-if="loading" class="load-more-spinner">
|
||||
<GoldSpinner :size="24" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
|
||||
{{ t('common.no_more') }}
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length && !initialLoading" class="empty">
|
||||
{{ t('wallet.no_records') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wallet-detail-page {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-light, #d4af37);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 4px 0 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filter-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.filter-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 56px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tx-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tx-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tx-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tx-row:active {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tx-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tx-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tx-type { font-weight: 700; color: var(--text); }
|
||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||
.neg { color: var(--danger); font-weight: 700; }
|
||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0 8px;
|
||||
}
|
||||
|
||||
.end-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
padding: 16px 0 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { txTypeKey } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
type TransactionDetail = {
|
||||
transactionId: string;
|
||||
@@ -30,7 +31,8 @@ const tx = ref<TransactionDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const notFound = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadTransaction() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/player/wallet/transactions/${route.params.transactionId}`);
|
||||
if (!data.data) {
|
||||
@@ -43,6 +45,17 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTransaction);
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: loadTransaction,
|
||||
});
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
function txLabel(type: string): string {
|
||||
@@ -86,6 +99,13 @@ function goBetDetail() {
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div class="top-bar">
|
||||
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||
</div>
|
||||
@@ -164,6 +184,14 @@ function goBetDetail() {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { isBetType, isDepositType, isWithdrawType, txTypeKey } from '../utils/walletTx';
|
||||
import { txTypeKey } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -20,22 +19,8 @@ type Transaction = {
|
||||
};
|
||||
|
||||
const items = ref<Transaction[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const loading = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const hasMore = ref(true);
|
||||
const typeFilter = ref('');
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const FILTERS = [
|
||||
{ key: '', label: 'wallet.filter_all' },
|
||||
{ key: 'deposit', label: 'wallet.filter_deposit' },
|
||||
{ key: 'withdraw', label: 'wallet.filter_withdraw' },
|
||||
{ key: 'bet', label: 'wallet.filter_bet' },
|
||||
];
|
||||
const loading = ref(true);
|
||||
const PREVIEW_COUNT = 15;
|
||||
|
||||
function txLabel(type: string): string {
|
||||
const key = txTypeKey(type);
|
||||
@@ -46,76 +31,33 @@ function txLabel(type: string): string {
|
||||
return type;
|
||||
}
|
||||
|
||||
function matchesFilter(tx: Transaction): boolean {
|
||||
if (!typeFilter.value) return true;
|
||||
if (typeFilter.value === 'deposit') return isDepositType(tx.transactionType) || tx.transactionType === 'MANUAL_ADJUST';
|
||||
if (typeFilter.value === 'withdraw') return isWithdrawType(tx.transactionType);
|
||||
if (typeFilter.value === 'bet') return isBetType(tx.transactionType);
|
||||
return true;
|
||||
}
|
||||
|
||||
function goDetail(tx: Transaction) {
|
||||
if (!tx.transactionId) return;
|
||||
router.push(`/wallet/transactions/${tx.transactionId}`);
|
||||
}
|
||||
|
||||
async function loadPage(p: number) {
|
||||
if (loading.value) return;
|
||||
function goWalletDetail() {
|
||||
router.push('/wallet/detail');
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/wallet/transactions', { params: { page: p } });
|
||||
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
|
||||
total.value = result.total ?? 0;
|
||||
const pageSize = result.pageSize ?? 20;
|
||||
const newItems = (result.items ?? []).filter(matchesFilter);
|
||||
if (p === 1) {
|
||||
items.value = newItems;
|
||||
} else {
|
||||
items.value = [...items.value, ...newItems];
|
||||
}
|
||||
hasMore.value = (result.items?.length ?? 0) >= pageSize;
|
||||
page.value = p;
|
||||
const { data } = await api.get('/player/wallet/transactions', { params: { page: 1 } });
|
||||
const result = data.data ?? { items: [] };
|
||||
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
loading.value = false;
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
initialLoading.value = true;
|
||||
loadPage(1);
|
||||
}
|
||||
|
||||
function changeFilter(key: string) {
|
||||
if (typeFilter.value === key) return;
|
||||
typeFilter.value = key;
|
||||
reset();
|
||||
}
|
||||
|
||||
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: async () => { await loadPage(1); },
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: fetchTransactions,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(1);
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
|
||||
loadPage(page.value + 1);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
if (sentinel.value) observer.observe(sentinel.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
onMounted(fetchTransactions);
|
||||
|
||||
const pullIndicatorStyle = () => ({
|
||||
height: `${pullDistance.value}px`,
|
||||
@@ -132,25 +74,18 @@ const pullIndicatorStyle = () => ({
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div v-if="initialLoading" class="state">
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
<span class="loading-text">{{ t('bet.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<WalletStatsPanel :items="items" />
|
||||
|
||||
<div class="filter-tabs">
|
||||
<div v-if="items.length" class="top-bar">
|
||||
<button
|
||||
v-for="f in FILTERS"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="filter-tab"
|
||||
:class="{ active: typeFilter === f.key }"
|
||||
@click="changeFilter(f.key)"
|
||||
>
|
||||
{{ t(f.label) }}
|
||||
</button>
|
||||
class="more-link"
|
||||
@click="goWalletDetail"
|
||||
>{{ t('wallet.view_all') }} ›</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="tx-list">
|
||||
@@ -169,22 +104,12 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
<div class="tx-meta">
|
||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||
<span class="tx-arrow">›</span>
|
||||
<span class="tx-arrow">›</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="sentinel" />
|
||||
|
||||
<div v-if="loading" class="load-more-spinner">
|
||||
<GoldSpinner :size="24" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
|
||||
{{ t('common.no_more') }}
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length && !initialLoading" class="empty">
|
||||
<div v-if="!items.length" class="empty">
|
||||
{{ t('wallet.no_records') }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -204,35 +129,6 @@ const pullIndicatorStyle = () => ({
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filter-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.filter-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -258,7 +154,7 @@ const pullIndicatorStyle = () => ({
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
@@ -293,23 +189,28 @@ const pullIndicatorStyle = () => ({
|
||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 4px 2px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.end-hint {
|
||||
text-align: center;
|
||||
.more-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-light);
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
padding: 16px 0 4px;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.more-link:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
|
||||
|
||||
Reference in New Issue
Block a user