diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts index dd3d176..32dcd35 100644 --- a/apps/admin/src/api.ts +++ b/apps/admin/src/api.ts @@ -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); }, ); diff --git a/apps/admin/src/components/AgentCreditContext.vue b/apps/admin/src/components/AgentCreditContext.vue new file mode 100644 index 0000000..6780e22 --- /dev/null +++ b/apps/admin/src/components/AgentCreditContext.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/apps/admin/src/components/WalletTransferContext.vue b/apps/admin/src/components/WalletTransferContext.vue new file mode 100644 index 0000000..be3c3e5 --- /dev/null +++ b/apps/admin/src/components/WalletTransferContext.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/apps/admin/src/composables/useAdminPlayerTransfer.ts b/apps/admin/src/composables/useAdminPlayerTransfer.ts new file mode 100644 index 0000000..dfeb908 --- /dev/null +++ b/apps/admin/src/composables/useAdminPlayerTransfer.ts @@ -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) { + const { t } = useAdminLocale(); + + const transferVisible = ref(false); + const transferLoading = ref(false); + const transferType = ref<'deposit' | 'withdraw'>('deposit'); + const transferTarget = ref(null); + const transferAmount = ref(100); + const transferRemark = ref(''); + const transferContext = ref(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, + }; +} diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index 5087abe..5474c7c 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -26,12 +26,14 @@ const zh: Record = { '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 = { 'breadcrumb.outright_edit': '编辑优胜冠军', 'role.admin': '系统管理员', 'role.agent': '代理账号', + 'role.tier1_agent': '一级代理', + 'role.tier2_agent': '二级代理', 'logout': '退出', 'lang': '语言', 'portal.admin': '平台后台', @@ -135,11 +139,21 @@ const zh: Record = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index c1ee8ea..8de3e72 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -94,13 +94,17 @@ export const adminPagesMs: Record = { '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 = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 1b8c8b5..41fdb57 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -94,13 +94,17 @@ export const adminPagesZh: Record = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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', diff --git a/apps/admin/src/i18n/form-validation.ts b/apps/admin/src/i18n/form-validation.ts index 70071bb..4a14010 100644 --- a/apps/admin/src/i18n/form-validation.ts +++ b/apps/admin/src/i18n/form-validation.ts @@ -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); +} diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index ed7a2a8..fa10123 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -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, () => {
{{ userInitial }}
diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts index 072348d..4f36e9c 100644 --- a/apps/admin/src/main.ts +++ b/apps/admin/src/main.ts @@ -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(); diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index 377c8ef..1a5647c 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -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; }); diff --git a/apps/admin/src/stores/auth.ts b/apps/admin/src/stores/auth.ts index ed9d32d..26f9e5f 100644 --- a/apps/admin/src/stores/auth.ts +++ b/apps/admin/src/stores/auth.ts @@ -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 | 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; + 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(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, }; } diff --git a/apps/admin/src/utils/agent-credit-context.ts b/apps/admin/src/utils/agent-credit-context.ts new file mode 100644 index 0000000..e4ad848 --- /dev/null +++ b/apps/admin/src/utils/agent-credit-context.ts @@ -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 { + 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); +} diff --git a/apps/admin/src/utils/expandable-table.ts b/apps/admin/src/utils/expandable-table.ts new file mode 100644 index 0000000..0c31d1e --- /dev/null +++ b/apps/admin/src/utils/expandable-table.ts @@ -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'; +} diff --git a/apps/admin/src/utils/session-hydrate.ts b/apps/admin/src/utils/session-hydrate.ts new file mode 100644 index 0000000..c3894ab --- /dev/null +++ b/apps/admin/src/utils/session-hydrate.ts @@ -0,0 +1,79 @@ +import { + reconcileStaffSessionFromToken, + useAuthStore, + type StaffUser, + type StaffUserType, +} from '../stores/auth'; + +let hydratePromise: Promise | 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 { + 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; + 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 { + 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(); +} diff --git a/apps/admin/src/utils/wallet-transfer-context.ts b/apps/admin/src/utils/wallet-transfer-context.ts new file mode 100644 index 0000000..6062394 --- /dev/null +++ b/apps/admin/src/utils/wallet-transfer-context.ts @@ -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; +} diff --git a/apps/admin/src/views/AgentManager.vue b/apps/admin/src/views/AgentManager.vue new file mode 100644 index 0000000..f003d0a --- /dev/null +++ b/apps/admin/src/views/AgentManager.vue @@ -0,0 +1,1644 @@ + + + + + + + diff --git a/apps/admin/src/views/Agents.vue b/apps/admin/src/views/Agents.vue index cf7c917..31503f5 100644 --- a/apps/admin/src/views/Agents.vue +++ b/apps/admin/src/views/Agents.vue @@ -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([]); const total = ref(0); @@ -56,6 +61,8 @@ const detail = ref(null); const editingId = ref(''); const creditForm = ref({ amount: 10000, remark: '' }); +const creditContext = ref(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) { + @@ -389,7 +408,12 @@ function creditTypeLabel(type: string) { - + + diff --git a/apps/admin/src/views/Cashback.vue b/apps/admin/src/views/Cashback.vue index 66a1c9b..e2ed1e7 100644 --- a/apps/admin/src/views/Cashback.vue +++ b/apps/admin/src/views/Cashback.vue @@ -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[]; 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; } diff --git a/apps/admin/src/views/HomeEntry.vue b/apps/admin/src/views/HomeEntry.vue index 05fb3f8..7f9500c 100644 --- a/apps/admin/src/views/HomeEntry.vue +++ b/apps/admin/src/views/HomeEntry.vue @@ -1,12 +1,28 @@ + + diff --git a/apps/admin/src/views/Login.vue b/apps/admin/src/views/Login.vue index 26c8efb..f319e34 100644 --- a/apps/admin/src/views/Login.vue +++ b/apps/admin/src/views/Login.vue @@ -93,6 +93,10 @@ async function login() { {{ t('login.quick_agent') }} agent1 + diff --git a/apps/admin/src/views/Users.vue b/apps/admin/src/views/Users.vue index 41bd257..7bdc649 100644 --- a/apps/admin/src/views/Users.vue +++ b/apps/admin/src/views/Users.vue @@ -1,5 +1,5 @@ diff --git a/apps/admin/src/views/agent/Players.vue b/apps/admin/src/views/agent/Players.vue index 9b8fb7c..801159a 100644 --- a/apps/admin/src/views/agent/Players.vue +++ b/apps/admin/src/views/agent/Players.vue @@ -1,61 +1,353 @@ diff --git a/apps/admin/src/views/agent/agent-player-form.ts b/apps/admin/src/views/agent/agent-player-form.ts new file mode 100644 index 0000000..3f17c58 --- /dev/null +++ b/apps/admin/src/views/agent/agent-player-form.ts @@ -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, + }; +} diff --git a/apps/admin/src/views/agent/agent-sub-agent-form.ts b/apps/admin/src/views/agent/agent-sub-agent-form.ts new file mode 100644 index 0000000..d2a68d3 --- /dev/null +++ b/apps/admin/src/views/agent/agent-sub-agent-form.ts @@ -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) { + return row.userStatus ?? row.status; +} diff --git a/apps/admin/src/views/dashboard-types.ts b/apps/admin/src/views/dashboard-types.ts index 89dd6a7..b3fb0da 100644 --- a/apps/admin/src/views/dashboard-types.ts +++ b/apps/admin/src/views/dashboard-types.ts @@ -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; + }; + recentBets: { + betNo: string; + username: string; + stake: string; + status: string; + placedAt: string; + }[]; + recentPlayers: { + id: string; + username: string; + status: string; + createdAt: string; + }[]; +} diff --git a/apps/admin/src/views/user-form.ts b/apps/admin/src/views/user-form.ts index b0a2a5a..20f12e5 100644 --- a/apps/admin/src/views/user-form.ts +++ b/apps/admin/src/views/user-form.ts @@ -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 { diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 6f01e5c..f6a0a77 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -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 }) { diff --git a/apps/api/src/applications/agent/agent-portal.controller.ts b/apps/api/src/applications/agent/agent-portal.controller.ts index 83da514..becd314 100644 --- a/apps/api/src/applications/agent/agent-portal.controller.ts +++ b/apps/api/src/applications/agent/agent-portal.controller.ts @@ -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, diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index 2fbb89f..3408f5d 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -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); } diff --git a/apps/api/src/domains/agent/agents.module.ts b/apps/api/src/domains/agent/agents.module.ts index 295c79f..01deca1 100644 --- a/apps/api/src/domains/agent/agents.module.ts +++ b/apps/api/src/domains/agent/agents.module.ts @@ -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], }) diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index 503f818..342af98 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -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 | 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 = {}; + 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, + })), }; } } diff --git a/apps/api/src/domains/identity/auth.controller.ts b/apps/api/src/domains/identity/auth.controller.ts index 16ff885..22cdfef 100644 --- a/apps/api/src/domains/identity/auth.controller.ts +++ b/apps/api/src/domains/identity/auth.controller.ts @@ -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') diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 670a773..6320c3c 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -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, }, }; } diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index 1a3950d..ae29d89 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -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 = {}; + 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 }; } diff --git a/apps/api/src/integration.spec.ts b/apps/api/src/integration.spec.ts index c423061..4289f38 100644 --- a/apps/api/src/integration.spec.ts +++ b/apps/api/src/integration.spec.ts @@ -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)', () => { diff --git a/apps/api/src/shared/config/system-config.service.ts b/apps/api/src/shared/config/system-config.service.ts index 096cdaf..c10d347 100644 --- a/apps/api/src/shared/config/system-config.service.ts +++ b/apps/api/src/shared/config/system-config.service.ts @@ -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 { + 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) { + 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(); + } } diff --git a/apps/player/src/api/index.ts b/apps/player/src/api/index.ts index 37ce008..20f4ea1 100644 --- a/apps/player/src/api/index.ts +++ b/apps/player/src/api/index.ts @@ -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); }, diff --git a/apps/player/src/assets/images/钱包.png b/apps/player/src/assets/images/钱包.png index a03dbca..86de48b 100644 Binary files a/apps/player/src/assets/images/钱包.png and b/apps/player/src/assets/images/钱包.png differ diff --git a/apps/player/src/components/WalletStatsPanel.vue b/apps/player/src/components/WalletStatsPanel.vue index 32b3b17..08ca16e 100644 --- a/apps/player/src/components/WalletStatsPanel.vue +++ b/apps/player/src/components/WalletStatsPanel.vue @@ -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 }; }); @@ -48,6 +65,11 @@ const stats = computed(() => { {{ t('wallet.stats_net') }} +
+
+ {{ formatMoney(stats.cashback, locale) }} + {{ t('wallet.stats_cashback') }} +
@@ -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; diff --git a/apps/player/src/layouts/MainLayout.vue b/apps/player/src/layouts/MainLayout.vue index 476e3bd..3e89f85 100644 --- a/apps/player/src/layouts/MainLayout.vue +++ b/apps/player/src/layouts/MainLayout.vue @@ -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); diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 222f524..346e6e1 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -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', diff --git a/apps/player/src/router/index.ts b/apps/player/src/router/index.ts index 8a7a08f..3cab3aa 100644 --- a/apps/player/src/router/index.ts +++ b/apps/player/src/router/index.ts @@ -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') }, diff --git a/apps/player/src/utils/walletTx.ts b/apps/player/src/utils/walletTx.ts index 1662c78..8fc3146 100644 --- a/apps/player/src/utils/walletTx.ts +++ b/apps/player/src/utils/walletTx.ts @@ -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'; +} diff --git a/apps/player/src/views/BetDetailView.vue b/apps/player/src/views/BetDetailView.vue index c3022e2..710a3a0 100644 --- a/apps/player/src/views/BetDetailView.vue +++ b/apps/player/src/views/BetDetailView.vue @@ -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(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(() => {