feat: multi-tier agent hierarchy, wallet ledger, and player UX polish
Add configurable agent max level and default sub-agent credit ratio, per-agent block direct player login on suspend, admin/agent wallet transaction views, and match detail my-bets section with refreshed player card styling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.pnpm-store/
|
||||||
|
release/
|
||||||
.claude/
|
.claude/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -659,8 +659,7 @@ body {
|
|||||||
border-color: #2a2a2a !important;
|
border-color: #2a2a2a !important;
|
||||||
}
|
}
|
||||||
.user-edit-dialog .el-dialog__body,
|
.user-edit-dialog .el-dialog__body,
|
||||||
.agent-edit-dialog .el-dialog__body,
|
.agent-edit-dialog .el-dialog__body {
|
||||||
.create-account-dialog .el-dialog__body {
|
|
||||||
max-height: min(70vh, 640px);
|
max-height: min(70vh, 640px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
285
apps/admin/src/components/PlayerWalletLedgerDialog.vue
Normal file
285
apps/admin/src/components/PlayerWalletLedgerDialog.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import api from '../api';
|
||||||
|
import AdminTableEmpty from './AdminTableEmpty.vue';
|
||||||
|
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||||
|
import { walletTxTypeKey } from '../utils/walletTx';
|
||||||
|
|
||||||
|
interface WalletTxRow {
|
||||||
|
id: string;
|
||||||
|
transactionId: string;
|
||||||
|
transactionType: string;
|
||||||
|
amount: string;
|
||||||
|
balanceBefore: string;
|
||||||
|
balanceAfter: string;
|
||||||
|
frozenBefore: string;
|
||||||
|
frozenAfter: string;
|
||||||
|
betNo: string | null;
|
||||||
|
operatorUsername: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
playerId: string;
|
||||||
|
playerUsername?: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t, locale, localeTag } = useAdminLocale();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v: boolean) => emit('update:modelValue', v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
const name = props.playerUsername?.trim() || props.playerId;
|
||||||
|
return t('user.wallet_ledger_dialog_title', { name });
|
||||||
|
});
|
||||||
|
|
||||||
|
const walletApiPath = computed(() =>
|
||||||
|
auth.isAdmin.value ? '/admin/wallet/transactions' : '/agent/wallet/ledger-transactions',
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = ref<WalletTxRow[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const loading = ref(false);
|
||||||
|
const typeCategory = ref('');
|
||||||
|
const dateRange = ref<[Date, Date] | null>(null);
|
||||||
|
|
||||||
|
function walletTypeLabel(type: string) {
|
||||||
|
const key = walletTxTypeKey(type);
|
||||||
|
return key ? t(key) : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(v: string) {
|
||||||
|
return new Date(v).toLocaleString(localeTag.value, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParams() {
|
||||||
|
if (!dateRange.value?.length) return {};
|
||||||
|
const [from, to] = dateRange.value;
|
||||||
|
const end = new Date(to);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
return {
|
||||||
|
dateFrom: from.toISOString(),
|
||||||
|
dateTo: end.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.playerId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(walletApiPath.value, {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
playerId: props.playerId,
|
||||||
|
typeCategory: typeCategory.value || undefined,
|
||||||
|
...dateParams(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
items.value = (data.data?.items ?? []) as WalletTxRow[];
|
||||||
|
total.value = data.data?.total ?? 0;
|
||||||
|
} catch {
|
||||||
|
items.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
page.value = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
typeCategory.value = '';
|
||||||
|
dateRange.value = null;
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.playerId] as const,
|
||||||
|
([open, id]) => {
|
||||||
|
if (open && id) {
|
||||||
|
resetFilters();
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="960px"
|
||||||
|
destroy-on-close
|
||||||
|
class="player-wallet-ledger-dialog"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form inline class="ledger-filter">
|
||||||
|
<el-form-item :label="t('finance.filter.date_range')">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
:start-placeholder="t('common.to')"
|
||||||
|
:end-placeholder="t('common.to')"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 240px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('finance.filter.type_category')">
|
||||||
|
<el-select v-model="typeCategory" clearable :placeholder="t('finance.filter.type_category_all')" style="width: 120px">
|
||||||
|
<el-option :label="t('finance.filter.type_category_deposit')" value="deposit" />
|
||||||
|
<el-option :label="t('finance.filter.type_category_bet')" value="bet" />
|
||||||
|
<el-option :label="t('finance.filter.type_category_cashback')" value="cashback" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table v-loading="loading" :key="`${locale}-${playerId}`" :data="items" stripe max-height="420">
|
||||||
|
<template #empty>
|
||||||
|
<AdminTableEmpty />
|
||||||
|
</template>
|
||||||
|
<el-table-column :label="t('audit.col.time')" min-width="150">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.transactionId }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('agent.col.credit_type')" width="92">
|
||||||
|
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.balance_change')" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||||
|
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
|
||||||
|
{{ formatAmount(row.amount) }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.balance_before')" width="96" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
|
||||||
|
<span>{{ formatAmount(row.balanceBefore) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.balance_after')" width="96" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
|
||||||
|
<span>{{ formatAmount(row.balanceAfter) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.frozen_before')" width="96" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.frozenBefore)" placement="top">
|
||||||
|
<span>{{ formatAmount(row.frozenBefore) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.frozen_after')" width="96" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.frozenAfter)" placement="top">
|
||||||
|
<span>{{ formatAmount(row.frozenAfter) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('finance.col.reference')" min-width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<router-link
|
||||||
|
v-if="row.betNo"
|
||||||
|
:to="{ path: '/bets', query: { keyword: row.betNo } }"
|
||||||
|
class="bet-link"
|
||||||
|
@click="visible = false"
|
||||||
|
>
|
||||||
|
{{ row.betNo }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="88">
|
||||||
|
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.field.remark')" min-width="100" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.remark ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="() => load()"
|
||||||
|
@size-change="() => { page = 1; load(); }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-filter {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-filter :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amt-pos {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amt-neg {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-link {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,7 +47,7 @@ const zh: Record<string, string> = {
|
|||||||
'nav.smoke_tests': '自动化测试',
|
'nav.smoke_tests': '自动化测试',
|
||||||
'nav.media': '媒体库',
|
'nav.media': '媒体库',
|
||||||
'nav.players': '直属玩家',
|
'nav.players': '直属玩家',
|
||||||
'nav.subAgents': '下级代理',
|
'nav.subAgents': '二级代理',
|
||||||
'nav.myBets': '注单查询',
|
'nav.myBets': '注单查询',
|
||||||
'nav.open_menu': '打开菜单',
|
'nav.open_menu': '打开菜单',
|
||||||
'nav.close_menu': '关闭菜单',
|
'nav.close_menu': '关闭菜单',
|
||||||
@@ -174,7 +174,7 @@ const zh: Record<string, string> = {
|
|||||||
'agent_dash.liability_child': '下级代理占用',
|
'agent_dash.liability_child': '下级代理占用',
|
||||||
'page.agent_players.title': '直属玩家',
|
'page.agent_players.title': '直属玩家',
|
||||||
'page.agent_players.desc': '管理你名下的直属玩家',
|
'page.agent_players.desc': '管理你名下的直属玩家',
|
||||||
'page.agent_sub.title': '下级代理',
|
'page.agent_sub.title': '二级代理',
|
||||||
'page.agent_sub.desc': '管理二级代理账号与授信分配',
|
'page.agent_sub.desc': '管理二级代理账号与授信分配',
|
||||||
'page.agent_bets.title': '注单查询',
|
'page.agent_bets.title': '注单查询',
|
||||||
'page.agent_bets.desc': '下级玩家的全部投注记录',
|
'page.agent_bets.desc': '下级玩家的全部投注记录',
|
||||||
@@ -247,7 +247,7 @@ const en: Record<string, string> = {
|
|||||||
'nav.smoke_tests': 'Smoke tests',
|
'nav.smoke_tests': 'Smoke tests',
|
||||||
'nav.media': 'Media Library',
|
'nav.media': 'Media Library',
|
||||||
'nav.players': 'My Players',
|
'nav.players': 'My Players',
|
||||||
'nav.subAgents': 'Sub-Agents',
|
'nav.subAgents': 'Tier-2 agents',
|
||||||
'nav.myBets': 'Bet Search',
|
'nav.myBets': 'Bet Search',
|
||||||
'nav.open_menu': 'Open menu',
|
'nav.open_menu': 'Open menu',
|
||||||
'nav.close_menu': 'Close menu',
|
'nav.close_menu': 'Close menu',
|
||||||
@@ -374,7 +374,7 @@ const en: Record<string, string> = {
|
|||||||
'agent_dash.liability_child': 'Sub-agent exposure',
|
'agent_dash.liability_child': 'Sub-agent exposure',
|
||||||
'page.agent_players.title': 'My players',
|
'page.agent_players.title': 'My players',
|
||||||
'page.agent_players.desc': 'Players under your account',
|
'page.agent_players.desc': 'Players under your account',
|
||||||
'page.agent_sub.title': 'Sub-agents',
|
'page.agent_sub.title': 'Tier-2 agents',
|
||||||
'page.agent_sub.desc': 'Manage tier-2 agents and credit allocation',
|
'page.agent_sub.desc': 'Manage tier-2 agents and credit allocation',
|
||||||
'page.agent_bets.title': 'Bet search',
|
'page.agent_bets.title': 'Bet search',
|
||||||
'page.agent_bets.desc': 'All bets from downstream players',
|
'page.agent_bets.desc': 'All bets from downstream players',
|
||||||
@@ -447,7 +447,7 @@ const ms: Record<string, string> = {
|
|||||||
'nav.smoke_tests': 'Ujian asap',
|
'nav.smoke_tests': 'Ujian asap',
|
||||||
'nav.media': 'Perpustakaan Media',
|
'nav.media': 'Perpustakaan Media',
|
||||||
'nav.players': 'Pemain saya',
|
'nav.players': 'Pemain saya',
|
||||||
'nav.subAgents': 'Sub-ejen',
|
'nav.subAgents': 'Ejen peringkat 2',
|
||||||
'nav.myBets': 'Carian pertaruhan',
|
'nav.myBets': 'Carian pertaruhan',
|
||||||
'nav.open_menu': 'Buka menu',
|
'nav.open_menu': 'Buka menu',
|
||||||
'nav.close_menu': 'Tutup menu',
|
'nav.close_menu': 'Tutup menu',
|
||||||
@@ -574,7 +574,7 @@ const ms: Record<string, string> = {
|
|||||||
'agent_dash.liability_child': 'Pendedahan ejen bawahan',
|
'agent_dash.liability_child': 'Pendedahan ejen bawahan',
|
||||||
'page.agent_players.title': 'Pemain saya',
|
'page.agent_players.title': 'Pemain saya',
|
||||||
'page.agent_players.desc': 'Pemain di bawah akaun anda',
|
'page.agent_players.desc': 'Pemain di bawah akaun anda',
|
||||||
'page.agent_sub.title': 'Sub-ejen',
|
'page.agent_sub.title': 'Ejen peringkat 2',
|
||||||
'page.agent_sub.desc': 'Urus ejen peringkat 2 dan peruntukan kredit',
|
'page.agent_sub.desc': 'Urus ejen peringkat 2 dan peruntukan kredit',
|
||||||
'page.agent_bets.title': 'Carian pertaruhan',
|
'page.agent_bets.title': 'Carian pertaruhan',
|
||||||
'page.agent_bets.desc': 'Semua pertaruhan pemain hiliran',
|
'page.agent_bets.desc': 'Semua pertaruhan pemain hiliran',
|
||||||
|
|||||||
@@ -100,13 +100,21 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'user.field.account_type': 'Jenis akaun',
|
'user.field.account_type': 'Jenis akaun',
|
||||||
'user.type.player': 'Pemain',
|
'user.type.player': 'Pemain',
|
||||||
'user.type.tier1_agent': 'Ejen peringkat 1',
|
'user.type.tier1_agent': 'Ejen peringkat 1',
|
||||||
'user.type.sub_agent': 'Sub-ejen',
|
'user.type.sub_agent': 'Ejen peringkat 2',
|
||||||
'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
|
'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
|
||||||
|
|
||||||
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
||||||
'agent.create_sub_btn': '+ Ejen peringkat 2 baharu',
|
'agent.create_sub_btn': '+ Ejen peringkat 2 baharu',
|
||||||
'agent.create_sub': 'Cipta sub-ejen',
|
'agent.create_sub': 'Cipta ejen peringkat 2',
|
||||||
'agent.hint.sub_agent_parent': 'Ejen peringkat 2 mesti di bawah ejen peringkat 1',
|
'agent.create_child_btn': '+ Sub-ejen baharu',
|
||||||
|
'agent.dialog.create_child_agent': 'Sub-ejen baharu',
|
||||||
|
'agent.create_level_agent': 'Cipta ejen peringkat {level}',
|
||||||
|
'agent.create_level_agent_btn': '+ Ejen peringkat {level} baharu',
|
||||||
|
'agent.level_name': 'Ejen peringkat {level}',
|
||||||
|
'agent.level_tab': 'Ejen peringkat {level}',
|
||||||
|
'agent.dialog.create_level_agent': 'Ejen peringkat {level} baharu',
|
||||||
|
'agent.hint.select_parent_for_level': 'Pilih ejen peringkat {level} sebagai induk',
|
||||||
|
'agent.err.parent_level_mismatch': 'Peringkat induk tidak sah untuk cipta ejen peringkat {level}',
|
||||||
'agent.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini',
|
'agent.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini',
|
||||||
'agent.filter.username_ph': 'Nama pengguna',
|
'agent.filter.username_ph': 'Nama pengguna',
|
||||||
'agent_mgr.tab.players': 'Pemain',
|
'agent_mgr.tab.players': 'Pemain',
|
||||||
@@ -150,6 +158,37 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
|
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
|
||||||
'finance.tab.credit': 'Lejar kredit',
|
'finance.tab.credit': 'Lejar kredit',
|
||||||
'finance.tab.transfer': 'Lejar pemindahan',
|
'finance.tab.transfer': 'Lejar pemindahan',
|
||||||
|
'finance.tab.wallet': 'Lejar dompet',
|
||||||
|
'finance.filter.type_category': 'Jenis transaksi',
|
||||||
|
'finance.filter.type_category_all': 'Semua',
|
||||||
|
'finance.filter.type_category_deposit': 'Pemindahan',
|
||||||
|
'finance.filter.type_category_bet': 'Pertaruhan',
|
||||||
|
'finance.filter.type_category_cashback': 'Rebat',
|
||||||
|
'finance.col.frozen_before': 'Beku sebelum',
|
||||||
|
'finance.col.frozen_after': 'Beku selepas',
|
||||||
|
'finance.col.reference': 'Pertaruhan berkaitan',
|
||||||
|
'finance.tx.adjust': 'Pelarasan baki',
|
||||||
|
'finance.tx.bet_freeze': 'Beku pertaruhan',
|
||||||
|
'finance.tx.bet_deduct': 'Potong pertaruhan',
|
||||||
|
'finance.tx.bet_win': 'Bayaran pertaruhan',
|
||||||
|
'finance.tx.bet_lose': 'Penyelesaian pertaruhan',
|
||||||
|
'finance.tx.bet_push': 'Refund seri',
|
||||||
|
'finance.tx.bet_refund': 'Refund pertaruhan',
|
||||||
|
'finance.tx.bet_void': 'Pertaruhan batal',
|
||||||
|
'finance.tx.cashback': 'Rebat',
|
||||||
|
'finance.tx.resettle': 'Penyelesaian semula',
|
||||||
|
'user.action.view_wallet_ledger': 'Lihat lejar dompet',
|
||||||
|
'user.wallet_ledger_dialog_title': 'Lejar dompet — {name}',
|
||||||
|
'agent.hierarchy.settings_title': 'Hierarki ejen',
|
||||||
|
'agent.hierarchy.settings_hint': '0 bermaksud tanpa had. Ejen di had atas tidak boleh cipta sub-ejen.',
|
||||||
|
'agent.hierarchy.max_level': 'Tahap ejen maksimum',
|
||||||
|
'agent.hierarchy.default_sub_credit_ratio': 'Nisbah kredit sub-ejen lalai',
|
||||||
|
'agent.hierarchy.create_credit_default_hint': 'Lalai {ratio}% ({amount}), tidak melebihi kredit induk; boleh diselaraskan',
|
||||||
|
'agent.hierarchy.create_credit_quick_hint': 'Kredit induk tersedia {amount} — klik nisbah untuk isi',
|
||||||
|
'agent.hierarchy.create_level_hint': 'Akan dicipta sebagai ejen peringkat {n}',
|
||||||
|
'agent.field.parent_agent': 'Ejen induk',
|
||||||
|
'agent.col.parent_chain': 'Rantaian induk',
|
||||||
|
'role.agent_level': 'Ejen peringkat {n}',
|
||||||
'finance.filter.date_range': 'Julat tarikh',
|
'finance.filter.date_range': 'Julat tarikh',
|
||||||
'finance.filter.player_ph': 'Nama pengguna pemain',
|
'finance.filter.player_ph': 'Nama pengguna pemain',
|
||||||
'finance.filter.parent_agent_ph': 'Nama/ID ejen induk',
|
'finance.filter.parent_agent_ph': 'Nama/ID ejen induk',
|
||||||
|
|||||||
@@ -106,7 +106,15 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent.create_btn': '+ 新建一级代理',
|
'agent.create_btn': '+ 新建一级代理',
|
||||||
'agent.create_sub_btn': '+ 新建二级代理',
|
'agent.create_sub_btn': '+ 新建二级代理',
|
||||||
'agent.create_sub': '创建二级代理',
|
'agent.create_sub': '创建二级代理',
|
||||||
'agent.hint.sub_agent_parent': '二级代理必须挂靠在一级代理名下',
|
'agent.create_child_btn': '+ 新建下级代理',
|
||||||
|
'agent.dialog.create_child_agent': '新建下级代理',
|
||||||
|
'agent.create_level_agent': '创建{level}级代理',
|
||||||
|
'agent.create_level_agent_btn': '+ 新建{level}级代理',
|
||||||
|
'agent.level_name': '{level}级代理',
|
||||||
|
'agent.level_tab': '{level}级代理',
|
||||||
|
'agent.dialog.create_level_agent': '新建{level}级代理',
|
||||||
|
'agent.hint.select_parent_for_level': '请选择 {level} 级代理作为上级',
|
||||||
|
'agent.err.parent_level_mismatch': '上级代理层级不正确,无法创建 {level} 级代理',
|
||||||
'agent.hint.creating_under_agent': '在此代理下创建账号',
|
'agent.hint.creating_under_agent': '在此代理下创建账号',
|
||||||
'agent.filter.username_ph': '用户名',
|
'agent.filter.username_ph': '用户名',
|
||||||
'agent_mgr.tab.players': '玩家',
|
'agent_mgr.tab.players': '玩家',
|
||||||
@@ -153,6 +161,38 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent.credit_tx.view_all': '查看全部额度流水',
|
'agent.credit_tx.view_all': '查看全部额度流水',
|
||||||
'finance.tab.credit': '额度流水',
|
'finance.tab.credit': '额度流水',
|
||||||
'finance.tab.transfer': '上下分流水',
|
'finance.tab.transfer': '上下分流水',
|
||||||
|
'finance.tab.wallet': '钱包流水',
|
||||||
|
'finance.filter.type_category': '流水类型',
|
||||||
|
'finance.filter.type_category_all': '全部',
|
||||||
|
'finance.filter.type_category_deposit': '上下分',
|
||||||
|
'finance.filter.type_category_bet': '投注',
|
||||||
|
'finance.filter.type_category_cashback': '返水',
|
||||||
|
'finance.col.frozen_before': '变动前冻结',
|
||||||
|
'finance.col.frozen_after': '变动后冻结',
|
||||||
|
'finance.col.reference': '关联注单',
|
||||||
|
'finance.tx.adjust': '余额调整',
|
||||||
|
'finance.tx.bet_freeze': '投注冻结',
|
||||||
|
'finance.tx.bet_deduct': '投注扣款',
|
||||||
|
'finance.tx.bet_win': '投注派彩',
|
||||||
|
'finance.tx.bet_lose': '投注结算',
|
||||||
|
'finance.tx.bet_push': '走水返还',
|
||||||
|
'finance.tx.bet_refund': '投注退款',
|
||||||
|
'finance.tx.bet_void': '注单作废',
|
||||||
|
'finance.tx.cashback': '返水',
|
||||||
|
'finance.tx.resettle': '重结算调整',
|
||||||
|
'user.action.view_wallet_ledger': '查看资金流水',
|
||||||
|
'user.wallet_ledger_dialog_title': '{name} 的资金流水',
|
||||||
|
'agent.hierarchy.settings_title': '代理层级设置',
|
||||||
|
'agent.hierarchy.settings_hint': '0 表示不限制代理层级;达到上限的代理将无法创建下级。下级默认授信比例用于创建下级代理时的预填额度。',
|
||||||
|
'agent.hierarchy.max_level': '最大代理层级',
|
||||||
|
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
|
||||||
|
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
|
||||||
|
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%({amount}),不超过上级可用授信,可手动调整',
|
||||||
|
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
|
||||||
|
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
|
||||||
|
'agent.field.parent_agent': '上级代理',
|
||||||
|
'agent.col.parent_chain': '上级链路',
|
||||||
|
'role.agent_level': '{n}级代理',
|
||||||
'finance.filter.date_range': '时间范围',
|
'finance.filter.date_range': '时间范围',
|
||||||
'finance.filter.player_ph': '玩家用户名',
|
'finance.filter.player_ph': '玩家用户名',
|
||||||
'finance.filter.parent_agent_ph': '上级代理用户名或 ID',
|
'finance.filter.parent_agent_ph': '上级代理用户名或 ID',
|
||||||
@@ -176,17 +216,15 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent.field.select_user': '选择用户',
|
'agent.field.select_user': '选择用户',
|
||||||
'agent.ph.select_user': '搜索玩家用户名',
|
'agent.ph.select_user': '搜索玩家用户名',
|
||||||
'agent.hint.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_title': '确认停用代理',
|
||||||
'agent.freeze.confirm_freeze_body': '确定停用代理「{name}」?停用后该代理无法登录代理端。',
|
'agent.freeze.confirm_freeze_body': '确定停用代理「{name}」?停用后该代理无法登录代理端。',
|
||||||
'agent.freeze.confirm_unfreeze_body': '确定恢复代理「{name}」为正常状态?',
|
'agent.freeze.confirm_unfreeze_body': '确定恢复代理「{name}」为正常状态?',
|
||||||
'agent.freeze.cascade_hint': '是否同时冻结该代理名下所有直属玩家账号?',
|
'agent.freeze.opt_freeze_direct_players': '同时冻结直属玩家',
|
||||||
'agent.freeze.cascade_label': '级联冻结直属玩家',
|
'agent.freeze.opt_block_player_login': '禁止直属玩家登录',
|
||||||
|
'agent.unfreeze.confirm_title': '确认恢复代理',
|
||||||
|
'agent.unfreeze.opt_unfreeze_direct_players': '同时解冻直属玩家',
|
||||||
'agent.msg.cascade_freeze_done': '已停用代理并冻结其直属玩家',
|
'agent.msg.cascade_freeze_done': '已停用代理并冻结其直属玩家',
|
||||||
|
'agent.msg.cascade_unfreeze_done': '已恢复代理并解冻其直属玩家',
|
||||||
'agent.msg.freeze_done': '已{action}',
|
'agent.msg.freeze_done': '已{action}',
|
||||||
|
|
||||||
'match.create_btn': '+ 新增联赛',
|
'match.create_btn': '+ 新增联赛',
|
||||||
@@ -931,13 +969,21 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'user.field.account_type': 'Account type',
|
'user.field.account_type': 'Account type',
|
||||||
'user.type.player': 'Player',
|
'user.type.player': 'Player',
|
||||||
'user.type.tier1_agent': 'Tier-1 agent',
|
'user.type.tier1_agent': 'Tier-1 agent',
|
||||||
'user.type.sub_agent': 'Sub-agent',
|
'user.type.sub_agent': 'Tier-2 agent',
|
||||||
'user.hint.account_type': 'Agents use credit limits; players can belong to an agent and receive top-ups',
|
'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_btn': '+ New tier-1 agent',
|
||||||
'agent.create_sub_btn': '+ New tier-2 agent',
|
'agent.create_sub_btn': '+ New tier-2 agent',
|
||||||
'agent.create_sub': 'Create sub-agent',
|
'agent.create_sub': 'Create tier-2 agent',
|
||||||
'agent.hint.sub_agent_parent': 'Tier-2 agents must belong to a tier-1 agent',
|
'agent.create_child_btn': '+ New sub-agent',
|
||||||
|
'agent.dialog.create_child_agent': 'New sub-agent',
|
||||||
|
'agent.create_level_agent': 'Create L{level} agent',
|
||||||
|
'agent.create_level_agent_btn': '+ New L{level} agent',
|
||||||
|
'agent.level_name': 'Tier-{level} agent',
|
||||||
|
'agent.level_tab': 'L{level} agents',
|
||||||
|
'agent.dialog.create_level_agent': 'New L{level} agent',
|
||||||
|
'agent.hint.select_parent_for_level': 'Select a level-{level} agent as parent',
|
||||||
|
'agent.err.parent_level_mismatch': 'Invalid parent level for creating a level-{level} agent',
|
||||||
'agent.hint.creating_under_agent': 'Create account under this agent',
|
'agent.hint.creating_under_agent': 'Create account under this agent',
|
||||||
'agent.filter.username_ph': 'Username',
|
'agent.filter.username_ph': 'Username',
|
||||||
'agent_mgr.tab.players': 'Players',
|
'agent_mgr.tab.players': 'Players',
|
||||||
@@ -984,6 +1030,38 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'agent.credit_tx.view_all': 'View all credit ledger',
|
'agent.credit_tx.view_all': 'View all credit ledger',
|
||||||
'finance.tab.credit': 'Credit ledger',
|
'finance.tab.credit': 'Credit ledger',
|
||||||
'finance.tab.transfer': 'Transfer ledger',
|
'finance.tab.transfer': 'Transfer ledger',
|
||||||
|
'finance.tab.wallet': 'Wallet ledger',
|
||||||
|
'finance.filter.type_category': 'Transaction type',
|
||||||
|
'finance.filter.type_category_all': 'All',
|
||||||
|
'finance.filter.type_category_deposit': 'Transfers',
|
||||||
|
'finance.filter.type_category_bet': 'Bets',
|
||||||
|
'finance.filter.type_category_cashback': 'Cashback',
|
||||||
|
'finance.col.frozen_before': 'Frozen before',
|
||||||
|
'finance.col.frozen_after': 'Frozen after',
|
||||||
|
'finance.col.reference': 'Related bet',
|
||||||
|
'finance.tx.adjust': 'Balance adjustment',
|
||||||
|
'finance.tx.bet_freeze': 'Bet freeze',
|
||||||
|
'finance.tx.bet_deduct': 'Bet deduct',
|
||||||
|
'finance.tx.bet_win': 'Bet payout',
|
||||||
|
'finance.tx.bet_lose': 'Bet settlement',
|
||||||
|
'finance.tx.bet_push': 'Push refund',
|
||||||
|
'finance.tx.bet_refund': 'Bet refund',
|
||||||
|
'finance.tx.bet_void': 'Bet void',
|
||||||
|
'finance.tx.cashback': 'Cashback',
|
||||||
|
'finance.tx.resettle': 'Resettlement',
|
||||||
|
'user.action.view_wallet_ledger': 'View wallet ledger',
|
||||||
|
'user.wallet_ledger_dialog_title': 'Wallet ledger — {name}',
|
||||||
|
'agent.hierarchy.settings_title': 'Agent hierarchy',
|
||||||
|
'agent.hierarchy.settings_hint': '0 means unlimited levels. Agents at the cap cannot create sub-agents. The default credit ratio pre-fills sub-agent credit limits.',
|
||||||
|
'agent.hierarchy.max_level': 'Max agent level',
|
||||||
|
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
|
||||||
|
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
|
||||||
|
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
|
||||||
|
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
|
||||||
|
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
|
||||||
|
'agent.field.parent_agent': 'Parent agent',
|
||||||
|
'agent.col.parent_chain': 'Parent chain',
|
||||||
|
'role.agent_level': 'Level {n} agent',
|
||||||
'finance.filter.date_range': 'Date range',
|
'finance.filter.date_range': 'Date range',
|
||||||
'finance.filter.player_ph': 'Player username',
|
'finance.filter.player_ph': 'Player username',
|
||||||
'finance.filter.parent_agent_ph': 'Parent agent username or ID',
|
'finance.filter.parent_agent_ph': 'Parent agent username or ID',
|
||||||
@@ -1007,17 +1085,15 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'agent.field.select_user': 'Select user',
|
'agent.field.select_user': 'Select user',
|
||||||
'agent.ph.select_user': 'Search player username',
|
'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.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_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_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.confirm_unfreeze_body': 'Restore agent "{name}" to active status?',
|
||||||
'agent.freeze.cascade_hint': 'Also freeze all direct player accounts under this agent?',
|
'agent.freeze.opt_freeze_direct_players': 'Also freeze direct players',
|
||||||
'agent.freeze.cascade_label': 'Cascade freeze direct players',
|
'agent.freeze.opt_block_player_login': 'Block direct player login',
|
||||||
|
'agent.unfreeze.confirm_title': 'Confirm restore agent',
|
||||||
|
'agent.unfreeze.opt_unfreeze_direct_players': 'Also unfreeze direct players',
|
||||||
'agent.msg.cascade_freeze_done': 'Agent suspended and direct players frozen',
|
'agent.msg.cascade_freeze_done': 'Agent suspended and direct players frozen',
|
||||||
|
'agent.msg.cascade_unfreeze_done': 'Agent restored and direct players unfrozen',
|
||||||
'agent.msg.freeze_done': '{action} completed',
|
'agent.msg.freeze_done': '{action} completed',
|
||||||
|
|
||||||
'match.create_btn': '+ New league',
|
'match.create_btn': '+ New league',
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ const topbarCrumbs = computed(() => resolveAdminBreadcrumb(route.path, t));
|
|||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
if (auth.isAdmin.value) return t('role.admin');
|
if (auth.isAdmin.value) return t('role.admin');
|
||||||
if (auth.isTier1Agent.value) return t('role.tier1_agent');
|
const level = auth.user.value?.agentLevel;
|
||||||
if (auth.isTier2Agent.value) return t('role.tier2_agent');
|
if (auth.isAgent.value && level != null && level > 0) {
|
||||||
|
return t('role.agent_level', { n: level });
|
||||||
|
}
|
||||||
return t('role.agent');
|
return t('role.agent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface StaffUser {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
agentLevel?: number | null;
|
agentLevel?: number | null;
|
||||||
|
maxAgentLevel?: number | null;
|
||||||
|
canManageSubAgents?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'manage_token';
|
const TOKEN_KEY = 'manage_token';
|
||||||
@@ -127,6 +129,15 @@ export function useAuthStore() {
|
|||||||
const isAgent = computed(() => resolveUserType() === 'AGENT');
|
const isAgent = computed(() => resolveUserType() === 'AGENT');
|
||||||
const isTier1Agent = computed(() => isAgent.value && user.value?.agentLevel === 1);
|
const isTier1Agent = computed(() => isAgent.value && user.value?.agentLevel === 1);
|
||||||
const isTier2Agent = computed(() => isAgent.value && user.value?.agentLevel === 2);
|
const isTier2Agent = computed(() => isAgent.value && user.value?.agentLevel === 2);
|
||||||
|
const canManageSubAgents = computed(() => {
|
||||||
|
if (!isAgent.value) return false;
|
||||||
|
if (user.value?.canManageSubAgents != null) return user.value.canManageSubAgents;
|
||||||
|
const level = user.value?.agentLevel;
|
||||||
|
const max = user.value?.maxAgentLevel;
|
||||||
|
if (level == null || level < 1) return false;
|
||||||
|
if (max == null || max === 0) return true;
|
||||||
|
return level < max;
|
||||||
|
});
|
||||||
const portalLabel = computed(() => (isAdmin.value ? '平台后台' : '代理后台'));
|
const portalLabel = computed(() => (isAdmin.value ? '平台后台' : '代理后台'));
|
||||||
|
|
||||||
function setSession(newToken: string, newUser: StaffUser) {
|
function setSession(newToken: string, newUser: StaffUser) {
|
||||||
@@ -149,6 +160,7 @@ export function useAuthStore() {
|
|||||||
isAgent,
|
isAgent,
|
||||||
isTier1Agent,
|
isTier1Agent,
|
||||||
isTier2Agent,
|
isTier2Agent,
|
||||||
|
canManageSubAgents,
|
||||||
portalLabel,
|
portalLabel,
|
||||||
setSession,
|
setSession,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
20
apps/admin/src/utils/agent-level-label.ts
Normal file
20
apps/admin/src/utils/agent-level-label.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const ZH_LEVEL_NUMERALS: Record<number, string> = {
|
||||||
|
1: '一',
|
||||||
|
2: '二',
|
||||||
|
3: '三',
|
||||||
|
4: '四',
|
||||||
|
5: '五',
|
||||||
|
6: '六',
|
||||||
|
7: '七',
|
||||||
|
8: '八',
|
||||||
|
9: '九',
|
||||||
|
10: '十',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 中文界面用「一、二、三…」,其他语言用阿拉伯数字 */
|
||||||
|
export function formatAgentLevelNumeral(level: number, locale: string): string {
|
||||||
|
if (locale.startsWith('zh') && ZH_LEVEL_NUMERALS[level]) {
|
||||||
|
return ZH_LEVEL_NUMERALS[level];
|
||||||
|
}
|
||||||
|
return String(level);
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ export async function hydrateStaffSession(): Promise<boolean> {
|
|||||||
locale: raw.locale,
|
locale: raw.locale,
|
||||||
role: raw.role,
|
role: raw.role,
|
||||||
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
|
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
|
||||||
|
maxAgentLevel: typeof raw.maxAgentLevel === 'number' ? raw.maxAgentLevel : null,
|
||||||
|
canManageSubAgents: raw.canManageSubAgents === true,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
23
apps/admin/src/utils/walletTx.ts
Normal file
23
apps/admin/src/utils/walletTx.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const TX_KEY_MAP: Record<string, string> = {
|
||||||
|
MANUAL_DEPOSIT: 'finance.tx.deposit',
|
||||||
|
MANUAL_WITHDRAW: 'finance.tx.withdraw',
|
||||||
|
MANUAL_ADJUST: 'finance.tx.adjust',
|
||||||
|
BET_FREEZE: 'finance.tx.bet_freeze',
|
||||||
|
BET_DEDUCT: 'finance.tx.bet_deduct',
|
||||||
|
BET_SETTLE_WIN: 'finance.tx.bet_win',
|
||||||
|
BET_SETTLE_LOSE: 'finance.tx.bet_lose',
|
||||||
|
BET_SETTLE_PUSH: 'finance.tx.bet_push',
|
||||||
|
BET_WIN: 'finance.tx.bet_win',
|
||||||
|
BET_REFUND: 'finance.tx.bet_refund',
|
||||||
|
BET_VOID: 'finance.tx.bet_void',
|
||||||
|
BET_VOID_REFUND: 'finance.tx.bet_void',
|
||||||
|
CASHBACK: 'finance.tx.cashback',
|
||||||
|
CASHBACK_DEPOSIT: 'finance.tx.cashback',
|
||||||
|
RESETTLE_REVERSE: 'finance.tx.resettle',
|
||||||
|
DEPOSIT: 'finance.tx.deposit',
|
||||||
|
WITHDRAW: 'finance.tx.withdraw',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function walletTxTypeKey(type: string): string {
|
||||||
|
return TX_KEY_MAP[type.toUpperCase()] ?? '';
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed, watch, reactive } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
@@ -37,11 +37,13 @@ import {
|
|||||||
formatAmountFull,
|
formatAmountFull,
|
||||||
shouldCompactAmount as shouldCompact,
|
shouldCompactAmount as shouldCompact,
|
||||||
} from '../utils/format-amount';
|
} from '../utils/format-amount';
|
||||||
|
import { formatAgentLevelNumeral } from '../utils/agent-level-label';
|
||||||
import {
|
import {
|
||||||
shouldToggleExpandOnRowClick,
|
shouldToggleExpandOnRowClick,
|
||||||
expandableTableRowClassName,
|
expandableTableRowClassName,
|
||||||
} from '../utils/expandable-table';
|
} from '../utils/expandable-table';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
|
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
|
||||||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||||||
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
||||||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||||||
@@ -59,15 +61,69 @@ const tier1PageSize = ref(20);
|
|||||||
const tier1Keyword = ref('');
|
const tier1Keyword = ref('');
|
||||||
const tier1FilterStatus = ref('');
|
const tier1FilterStatus = ref('');
|
||||||
|
|
||||||
/* ─── Tier-2 agent list ─── */
|
/* ─── Sub-agent lists by level (L2, L3, …) ─── */
|
||||||
const tier2Agents = ref<AgentRow[]>([]);
|
type SubAgentLevelState = {
|
||||||
const tier2Total = ref(0);
|
agents: AgentRow[];
|
||||||
const tier2Page = ref(1);
|
total: number;
|
||||||
const tier2PageSize = ref(20);
|
page: number;
|
||||||
const tier2Keyword = ref('');
|
pageSize: number;
|
||||||
const tier2FilterStatus = ref('');
|
keyword: string;
|
||||||
|
filterStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
/* ─── View tab: players | tier1Agents | tier2Agents ─── */
|
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
|
||||||
|
const agentLevelCounts = ref<Record<number, number>>({});
|
||||||
|
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
|
||||||
|
|
||||||
|
function ensureSubAgentState(level: number): SubAgentLevelState {
|
||||||
|
if (!subAgentLevelState[level]) {
|
||||||
|
subAgentLevelState[level] = {
|
||||||
|
agents: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
keyword: '',
|
||||||
|
filterStatus: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return subAgentLevelState[level];
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLevelTabName(level: number) {
|
||||||
|
return `agentLevel-${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lvlLabel(level: number) {
|
||||||
|
return formatAgentLevelNumeral(level, localeTag.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentTierName(level: number) {
|
||||||
|
return t('agent.level_name', { level: lvlLabel(level) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLevelTabLabel(level: number) {
|
||||||
|
const count = agentLevelCounts.value[level] ?? 0;
|
||||||
|
return `${agentTierName(level)} (${count})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleSubAgentTabLevels = computed(() => {
|
||||||
|
const counts = agentLevelCounts.value;
|
||||||
|
const max = hierarchySettings.value.maxAgentLevel;
|
||||||
|
const levels = new Set<number>([2]);
|
||||||
|
if (max === 0) {
|
||||||
|
for (const [lvl, cnt] of Object.entries(counts)) {
|
||||||
|
const n = Number(lvl);
|
||||||
|
if (n >= 3 && cnt > 0) levels.add(n);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let n = 3; n <= max; n++) {
|
||||||
|
if ((counts[n] ?? 0) > 0) levels.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...levels].sort((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── View tab: players | tier1Agents | agentLevel-N ─── */
|
||||||
const activeViewTab = ref('players');
|
const activeViewTab = ref('players');
|
||||||
|
|
||||||
/* ─── All players list ─── */
|
/* ─── All players list ─── */
|
||||||
@@ -86,7 +142,8 @@ const expandedSet = ref(new Set<string>());
|
|||||||
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
||||||
const expandLoading = ref<Record<string, boolean>>({});
|
const expandLoading = ref<Record<string, boolean>>({});
|
||||||
const tier1AgentTableRef = ref();
|
const tier1AgentTableRef = ref();
|
||||||
const tier2AgentTableRef = ref();
|
|
||||||
|
const createToolbarChildLevel = ref<number | null>(null);
|
||||||
|
|
||||||
/* ─── Dialogs ─── */
|
/* ─── Dialogs ─── */
|
||||||
const createVisible = ref(false);
|
const createVisible = ref(false);
|
||||||
@@ -132,11 +189,16 @@ const bettingLimits = ref({
|
|||||||
});
|
});
|
||||||
const settingsSaving = ref(false);
|
const settingsSaving = ref(false);
|
||||||
const limitsSaving = ref(false);
|
const limitsSaving = ref(false);
|
||||||
const agentSuspendSettings = ref({
|
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
|
||||||
suspendFreezeDirectPlayers: false,
|
const freezeAgentVisible = ref(false);
|
||||||
suspendBlockPlayerLogin: false,
|
const freezeAgentLoading = ref(false);
|
||||||
|
const freezeAgentTarget = ref<AgentRow | null>(null);
|
||||||
|
const freezeAgentForm = ref({
|
||||||
|
freezeDirectPlayers: false,
|
||||||
|
blockDirectPlayerLogin: false,
|
||||||
|
unfreezeDirectPlayers: false,
|
||||||
});
|
});
|
||||||
const agentSuspendSaving = ref(false);
|
const hierarchySaving = ref(false);
|
||||||
const resetAllowed = ref(false);
|
const resetAllowed = ref(false);
|
||||||
const resetLoading = ref(false);
|
const resetLoading = ref(false);
|
||||||
const resetConfirmPhrase = ref('');
|
const resetConfirmPhrase = ref('');
|
||||||
@@ -144,27 +206,161 @@ const settingsCollapseOpen = ref<string[]>([]);
|
|||||||
|
|
||||||
const createDialogTitle = computed(() => {
|
const createDialogTitle = computed(() => {
|
||||||
if (createAccountMode.value === 1) return t('agent.dialog.create');
|
if (createAccountMode.value === 1) return t('agent.dialog.create');
|
||||||
if (createAccountMode.value === 2) return t('agent_portal.create_sub_agent_dialog');
|
if (createAccountMode.value === 2) {
|
||||||
|
const lvl = createTargetAgentLevel.value;
|
||||||
|
if (lvl === 2) return t('agent_portal.create_sub_agent_dialog');
|
||||||
|
if (lvl != null) return t('agent.dialog.create_level_agent', { level: lvlLabel(lvl) });
|
||||||
|
return t('agent.dialog.create_child_agent');
|
||||||
|
}
|
||||||
return t('user.dialog.create');
|
return t('user.dialog.create');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createSubAgentLevelPreview = computed(() => {
|
||||||
|
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
|
||||||
|
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
|
||||||
|
return parentLevel != null ? parentLevel + 1 : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTargetAgentLevel = computed(() => {
|
||||||
|
if (createAccountMode.value !== 2) return null;
|
||||||
|
if (createToolbarChildLevel.value != null) return createToolbarChildLevel.value;
|
||||||
|
return createSubAgentLevelPreview.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveParentAgentLevel(agentId: string): number | null {
|
||||||
|
const opt = agentOptions.value.find((a) => a.id === agentId);
|
||||||
|
if (opt) return opt.level;
|
||||||
|
const row = findAgentRowByUserId(agentId);
|
||||||
|
if (row) return row.level;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentOptionsForChildLevel(childLevel: number) {
|
||||||
|
if (childLevel < 2) return [];
|
||||||
|
const requiredParentLevel = childLevel - 1;
|
||||||
|
return parentAgentOptionsForCreate.value.filter((a) => a.level === requiredParentLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function childAgentActionLabel(parentLevel: number) {
|
||||||
|
return t('agent.create_level_agent', { level: lvlLabel(parentLevel + 1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createParentCreditCache = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
function findAgentRowByUserId(userId: string): AgentRow | undefined {
|
||||||
|
const tier1 = tier1Agents.value.find((a) => a.userId === userId);
|
||||||
|
if (tier1) return tier1;
|
||||||
|
for (const lvl of visibleSubAgentTabLevels.value) {
|
||||||
|
const hit = subAgentLevelState[lvl]?.agents.find((a) => a.userId === userId);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCreateParentCredit(agentId: string) {
|
||||||
|
const hit = findAgentRowByUserId(agentId);
|
||||||
|
if (hit) {
|
||||||
|
createParentCreditCache.value[agentId] = hit.availableCredit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createParentCreditCache.value[agentId]) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/agents/${agentId}`);
|
||||||
|
createParentCreditCache.value[agentId] = String(data.data.availableCredit ?? '0');
|
||||||
|
} catch {
|
||||||
|
createParentCreditCache.value[agentId] = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createParentAvailableCredit = computed(() => {
|
||||||
|
if (createAccountMode.value !== 2 || !createParentAgentId.value) return null;
|
||||||
|
const id = createParentAgentId.value;
|
||||||
|
const hit = findAgentRowByUserId(id);
|
||||||
|
if (hit) return hit.availableCredit;
|
||||||
|
return createParentCreditCache.value[id] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSubCreditMax = computed(() => {
|
||||||
|
const n = Number(createParentAvailableCredit.value ?? 0);
|
||||||
|
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeSubAgentCreditByRatio(available: number, ratioPercent: number): number {
|
||||||
|
if (available <= 0) return 0;
|
||||||
|
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
|
||||||
|
const raw = available * pct;
|
||||||
|
const rounded = pct >= 1 ? available : Math.floor(raw / 100) * 100;
|
||||||
|
return Math.min(available, Math.max(0, rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
const creditQuickRatios = [10, 15, 20, 30] as const;
|
||||||
|
|
||||||
|
function computeDefaultSubAgentCreditLimit(available: number): number {
|
||||||
|
return computeSubAgentCreditByRatio(available, hierarchySettings.value.defaultSubAgentCreditRatio ?? 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
|
||||||
|
createForm.value.creditLimit = computeSubAgentCreditByRatio(createSubCreditMax.value, ratioPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCreateSubAgentDefaultCredit() {
|
||||||
|
if (createAccountMode.value !== 2) return;
|
||||||
|
createForm.value.creditLimit = computeDefaultSubAgentCreditLimit(createSubCreditMax.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [createVisible.value, createAccountMode.value, createParentAgentId.value] as const,
|
||||||
|
([visible, mode, parentId]) => {
|
||||||
|
if (!visible || mode !== 2 || !parentId) return;
|
||||||
|
void ensureCreateParentCredit(parentId).then(() => {
|
||||||
|
applyCreateSubAgentDefaultCredit();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function canAgentCreateSub(row: Pick<AgentRow, 'level'>) {
|
||||||
|
const max = hierarchySettings.value.maxAgentLevel;
|
||||||
|
if (max === 0) return true;
|
||||||
|
return row.level < max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentChainLabel(row: Pick<AgentRow, 'parentChainLabel' | 'parentUsername'>) {
|
||||||
|
if (row.parentChainLabel) return row.parentChainLabel;
|
||||||
|
return row.parentUsername ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1));
|
const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1));
|
||||||
|
|
||||||
|
const parentAgentOptionsForCreate = computed(() => {
|
||||||
|
const max = hierarchySettings.value.maxAgentLevel;
|
||||||
|
return agentOptions.value.filter((a) => max === 0 || a.level < max);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createParentSelectOptions = computed(() => {
|
||||||
|
const childLevel = createToolbarChildLevel.value;
|
||||||
|
if (childLevel == null || childLevel < 2) return [];
|
||||||
|
return parentOptionsForChildLevel(childLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createParentSelectPlaceholder = computed(() => {
|
||||||
|
const childLevel = createToolbarChildLevel.value;
|
||||||
|
if (childLevel == null || childLevel < 2) return t('user.filter.agent_ph');
|
||||||
|
return t('agent.hint.select_parent_for_level', { level: lvlLabel(childLevel - 1) });
|
||||||
|
});
|
||||||
|
|
||||||
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
|
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
|
||||||
if (a.level === 2 && a.parentUsername) {
|
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
|
||||||
return `${a.parentUsername} / ${a.username} (#${a.id})`;
|
return `L${a.level} ${chain} (#${a.id})`;
|
||||||
}
|
|
||||||
return `${a.username} (#${a.id})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCreateParentLabel(agentId: string) {
|
function resolveCreateParentLabel(agentId: string) {
|
||||||
const hit = agentOptions.value.find((a) => a.id === agentId);
|
const opt = agentOptions.value.find((a) => a.id === agentId);
|
||||||
if (hit) return agentOptionLabel(hit);
|
if (opt) return agentOptionLabel(opt);
|
||||||
const tier1 = tier1Agents.value.find((a) => a.userId === agentId);
|
const tier1 = tier1Agents.value.find((a) => a.userId === agentId);
|
||||||
if (tier1) return tier1.username;
|
if (tier1) return tier1.username;
|
||||||
const tier2 = tier2Agents.value.find((a) => a.userId === agentId);
|
const row = findAgentRowByUserId(agentId);
|
||||||
if (tier2) {
|
if (row) {
|
||||||
return tier2.parentUsername ? `${tier2.parentUsername} / ${tier2.username}` : tier2.username;
|
return row.parentUsername ? `${row.parentUsername} / ${row.username}` : row.username;
|
||||||
}
|
}
|
||||||
return agentId;
|
return agentId;
|
||||||
}
|
}
|
||||||
@@ -173,12 +369,16 @@ function resolveCreateParentLabel(agentId: string) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPlayerSettings();
|
loadPlayerSettings();
|
||||||
loadBettingLimits();
|
loadBettingLimits();
|
||||||
loadAgentSuspendSettings();
|
loadHierarchySettings();
|
||||||
loadResetDatabaseStatus();
|
loadResetDatabaseStatus();
|
||||||
loadAgentOptions();
|
loadAgentOptions();
|
||||||
loadAllPlayers();
|
loadAllPlayers();
|
||||||
loadTier1Agents();
|
loadTier1Agents();
|
||||||
loadTier2Agents();
|
loadAgentLevelCounts().then(() => {
|
||||||
|
for (const lvl of visibleSubAgentTabLevels.value) {
|
||||||
|
loadSubAgentsAtLevel(lvl);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ─── Load tier-1 agents ─── */
|
/* ─── Load tier-1 agents ─── */
|
||||||
@@ -212,40 +412,63 @@ function searchTier1Agents() {
|
|||||||
loadTier1Agents();
|
loadTier1Agents();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Load tier-2 agents ─── */
|
async function loadAgentLevelCounts() {
|
||||||
async function loadTier2Agents() {
|
try {
|
||||||
|
const { data } = await api.get('/admin/agents/level-counts');
|
||||||
|
const raw = data.data ?? {};
|
||||||
|
const normalized: Record<number, number> = {};
|
||||||
|
for (const [lvl, cnt] of Object.entries(raw)) {
|
||||||
|
normalized[Number(lvl)] = Number(cnt) || 0;
|
||||||
|
}
|
||||||
|
agentLevelCounts.value = normalized;
|
||||||
|
} catch {
|
||||||
|
agentLevelCounts.value = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubAgentsAtLevel(level: number) {
|
||||||
|
const st = ensureSubAgentState(level);
|
||||||
const { data } = await api.get('/admin/agents', {
|
const { data } = await api.get('/admin/agents', {
|
||||||
params: {
|
params: {
|
||||||
page: tier2Page.value,
|
page: st.page,
|
||||||
pageSize: tier2PageSize.value,
|
pageSize: st.pageSize,
|
||||||
keyword: tier2Keyword.value.trim() || undefined,
|
keyword: st.keyword.trim() || undefined,
|
||||||
status: tier2FilterStatus.value || undefined,
|
status: st.filterStatus || undefined,
|
||||||
level: 2,
|
level,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tier2Agents.value = data.data.items as AgentRow[];
|
st.agents = data.data.items as AgentRow[];
|
||||||
tier2Total.value = data.data.total;
|
st.total = data.data.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTier2PageChange(p: number) {
|
function onSubAgentPageChange(level: number, p: number) {
|
||||||
tier2Page.value = p;
|
ensureSubAgentState(level).page = p;
|
||||||
loadTier2Agents();
|
loadSubAgentsAtLevel(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTier2SizeChange(size: number) {
|
function onSubAgentSizeChange(level: number, size: number) {
|
||||||
tier2PageSize.value = size;
|
const st = ensureSubAgentState(level);
|
||||||
tier2Page.value = 1;
|
st.pageSize = size;
|
||||||
loadTier2Agents();
|
st.page = 1;
|
||||||
|
loadSubAgentsAtLevel(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchTier2Agents() {
|
function searchSubAgentsAtLevel(level: number) {
|
||||||
tier2Page.value = 1;
|
ensureSubAgentState(level).page = 1;
|
||||||
loadTier2Agents();
|
loadSubAgentsAtLevel(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadSubAgentTabs() {
|
||||||
|
return loadAgentLevelCounts().then(() => {
|
||||||
|
for (const lvl of visibleSubAgentTabLevels.value) {
|
||||||
|
loadSubAgentsAtLevel(lvl);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadAgentLists() {
|
function reloadAgentLists() {
|
||||||
loadTier1Agents();
|
loadTier1Agents();
|
||||||
loadTier2Agents();
|
void reloadSubAgentTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Load main agent list ─── */
|
/* ─── Load main agent list ─── */
|
||||||
@@ -313,10 +536,24 @@ function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent
|
|||||||
onAgentRowClick(row, tier1AgentTableRef, _column, event);
|
onAgentRowClick(row, tier1AgentTableRef, _column, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTier2AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
|
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
|
||||||
onAgentRowClick(row, tier2AgentTableRef, _column, event);
|
const tableRef = { value: subAgentTableRefs.value[level] };
|
||||||
|
onAgentRowClick(row, tableRef, _column, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(activeViewTab, (tab) => {
|
||||||
|
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||||||
|
if (m) loadSubAgentsAtLevel(Number(m[1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(visibleSubAgentTabLevels, (levels, prev) => {
|
||||||
|
for (const lvl of levels) {
|
||||||
|
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
|
||||||
|
loadSubAgentsAtLevel(lvl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ─── Expansion ─── */
|
/* ─── Expansion ─── */
|
||||||
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
|
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
|
||||||
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
|
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
|
||||||
@@ -442,30 +679,40 @@ async function savePlayerSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAgentSuspendSettings() {
|
async function loadHierarchySettings() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/admin/agents/settings/suspend');
|
const { data } = await api.get('/admin/agents/settings/hierarchy');
|
||||||
agentSuspendSettings.value = data.data;
|
hierarchySettings.value = data.data;
|
||||||
} catch {
|
} catch {
|
||||||
/* defaults */
|
hierarchySettings.value = { maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAgentSuspendSettings() {
|
async function saveHierarchySettings() {
|
||||||
agentSuspendSaving.value = true;
|
hierarchySaving.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
|
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
|
||||||
agentSuspendSettings.value = data.data;
|
hierarchySettings.value = data.data;
|
||||||
ElMessage.success(t('msg.saved'));
|
ElMessage.success(t('msg.saved'));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
loadAgentSuspendSettings();
|
loadHierarchySettings();
|
||||||
} finally {
|
} finally {
|
||||||
agentSuspendSaving.value = false;
|
hierarchySaving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const walletLedgerVisible = ref(false);
|
||||||
|
const walletLedgerPlayerId = ref('');
|
||||||
|
const walletLedgerPlayerUsername = ref<string | null>(null);
|
||||||
|
|
||||||
|
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
|
||||||
|
walletLedgerPlayerId.value = playerId;
|
||||||
|
walletLedgerPlayerUsername.value = playerUsername ?? null;
|
||||||
|
walletLedgerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Create (unified) ─── */
|
/* ─── Create (unified) ─── */
|
||||||
function openCreateAccount() {
|
function openCreateAccount() {
|
||||||
createForm.value = emptyPlayerCreateForm();
|
createForm.value = emptyPlayerCreateForm();
|
||||||
@@ -500,15 +747,19 @@ function openCreateSubAgent(parentAgentUserId: string) {
|
|||||||
createForm.value.asTier1Agent = false;
|
createForm.value.asTier1Agent = false;
|
||||||
createParentAgentId.value = parentAgentUserId;
|
createParentAgentId.value = parentAgentUserId;
|
||||||
createParentLocked.value = true;
|
createParentLocked.value = true;
|
||||||
|
const parentLevel = resolveParentAgentLevel(parentAgentUserId);
|
||||||
|
createToolbarChildLevel.value = parentLevel != null ? parentLevel + 1 : null;
|
||||||
createAccountMode.value = 2;
|
createAccountMode.value = 2;
|
||||||
createVisible.value = true;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateSubAgentFromToolbar() {
|
function openCreateSubAgentFromToolbar(childLevel: number) {
|
||||||
createForm.value = emptyPlayerCreateForm();
|
createForm.value = emptyPlayerCreateForm();
|
||||||
createForm.value.asTier1Agent = false;
|
createForm.value.asTier1Agent = false;
|
||||||
createParentAgentId.value = '';
|
|
||||||
createParentLocked.value = false;
|
createParentLocked.value = false;
|
||||||
|
createToolbarChildLevel.value = childLevel;
|
||||||
|
const parents = parentOptionsForChildLevel(childLevel);
|
||||||
|
createParentAgentId.value = parents.length === 1 ? parents[0].id : '';
|
||||||
createAccountMode.value = 2;
|
createAccountMode.value = 2;
|
||||||
createVisible.value = true;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -519,10 +770,20 @@ async function submitCreate() {
|
|||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
if (isSubAgent) {
|
if (isSubAgent) {
|
||||||
if (!createParentAgentId.value) throw new Error(t('agent.hint.sub_agent_parent'));
|
if (!createParentAgentId.value) {
|
||||||
|
throw new Error(t('agent.hint.select_parent_for_level', { level: lvlLabel((createToolbarChildLevel.value ?? 2) - 1) }));
|
||||||
|
}
|
||||||
|
const parentLevel = resolveParentAgentLevel(createParentAgentId.value);
|
||||||
|
const targetLevel = createTargetAgentLevel.value;
|
||||||
|
if (targetLevel == null || parentLevel == null || parentLevel !== targetLevel - 1) {
|
||||||
|
throw new Error(t('agent.err.parent_level_mismatch', { level: lvlLabel(targetLevel ?? 0) }));
|
||||||
|
}
|
||||||
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
|
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
|
||||||
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
|
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
|
||||||
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
||||||
|
if (createForm.value.creditLimit > createSubCreditMax.value) {
|
||||||
|
throw new Error(t('err.insufficient_credit'));
|
||||||
|
}
|
||||||
payload = {
|
payload = {
|
||||||
username: createForm.value.username.trim(),
|
username: createForm.value.username.trim(),
|
||||||
password: createForm.value.password,
|
password: createForm.value.password,
|
||||||
@@ -555,8 +816,12 @@ async function submitCreate() {
|
|||||||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||||||
);
|
);
|
||||||
createVisible.value = false;
|
createVisible.value = false;
|
||||||
|
const createdLevel = isSubAgent ? createTargetAgentLevel.value : null;
|
||||||
load();
|
load();
|
||||||
refreshExpandedParents();
|
refreshExpandedParents();
|
||||||
|
if (createdLevel != null && createdLevel >= 2) {
|
||||||
|
activeViewTab.value = agentLevelTabName(createdLevel);
|
||||||
|
}
|
||||||
const parentId = createParentAgentId.value || createForm.value.parentId;
|
const parentId = createParentAgentId.value || createForm.value.parentId;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
await loadExpansionData(parentId);
|
await loadExpansionData(parentId);
|
||||||
@@ -752,87 +1017,58 @@ async function toggleFreezePlayer(row: PlayerRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Freeze / Unfreeze Agent ─── */
|
/* ─── Freeze / Unfreeze Agent ─── */
|
||||||
async function toggleFreezeAgent(row: AgentRow) {
|
const freezeAgentIsSuspend = computed(() => {
|
||||||
const accountStatus = subAgentAccountStatus(row);
|
if (!freezeAgentTarget.value) return true;
|
||||||
const freeze = accountStatus === 'ACTIVE';
|
return subAgentAccountStatus(freezeAgentTarget.value) === 'ACTIVE';
|
||||||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
});
|
||||||
|
|
||||||
if (!freeze) {
|
function toggleFreezeAgent(row: AgentRow) {
|
||||||
// Unfreeze: simple confirm
|
freezeAgentTarget.value = row;
|
||||||
try {
|
freezeAgentForm.value = {
|
||||||
await ElMessageBox.confirm(
|
freezeDirectPlayers: false,
|
||||||
t('agent.freeze.confirm_unfreeze_body', { name: row.username }),
|
blockDirectPlayerLogin: false,
|
||||||
t('agent.freeze.confirm_freeze_title'),
|
unfreezeDirectPlayers: false,
|
||||||
{ type: 'info', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
};
|
||||||
);
|
freezeAgentVisible.value = true;
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api.put(`/admin/agents/${row.userId}`, { status: 'ACTIVE' });
|
|
||||||
ElMessage.success(t('agent.msg.freeze_done', { action }));
|
|
||||||
load();
|
|
||||||
refreshExpandedParents();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freeze: offer cascade option via a custom dialog
|
|
||||||
let freezeDirectPlayers = false;
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
t('agent.freeze.confirm_freeze_body', { name: row.username }),
|
|
||||||
t('agent.freeze.confirm_freeze_title'),
|
|
||||||
{
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: action,
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
distinguishCancelAndClose: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After confirming freeze, ask about cascade (only when enabled in settings)
|
|
||||||
if (row.directPlayerCount > 0 && agentSuspendSettings.value.suspendFreezeDirectPlayers) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
t('agent.freeze.cascade_hint'),
|
|
||||||
t('agent.freeze.cascade_label'),
|
|
||||||
{
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: t('common.yes') || '是',
|
|
||||||
cancelButtonText: t('common.no') || '否',
|
|
||||||
distinguishCancelAndClose: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
freezeDirectPlayers = true;
|
|
||||||
} catch {
|
|
||||||
freezeDirectPlayers = false;
|
|
||||||
}
|
|
||||||
} else if (row.directPlayerCount > 0 && !agentSuspendSettings.value.suspendFreezeDirectPlayers) {
|
|
||||||
ElMessage.info(t('agent.suspend.cascade_disabled_hint'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitFreezeAgent() {
|
||||||
|
const row = freezeAgentTarget.value;
|
||||||
|
if (!row) return;
|
||||||
|
const suspend = freezeAgentIsSuspend.value;
|
||||||
|
const action = suspend ? t('common.freeze') : t('common.unfreeze');
|
||||||
|
freezeAgentLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
if (suspend) {
|
||||||
await api.put(`/admin/agents/${row.userId}`, {
|
await api.put(`/admin/agents/${row.userId}`, {
|
||||||
status: 'SUSPENDED',
|
status: 'SUSPENDED',
|
||||||
freezeDirectPlayers,
|
freezeDirectPlayers: freezeAgentForm.value.freezeDirectPlayers,
|
||||||
|
blockDirectPlayerLogin: freezeAgentForm.value.blockDirectPlayerLogin,
|
||||||
});
|
});
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
freezeDirectPlayers
|
freezeAgentForm.value.freezeDirectPlayers
|
||||||
? t('agent.msg.cascade_freeze_done')
|
? t('agent.msg.cascade_freeze_done')
|
||||||
: t('agent.msg.freeze_done', { action }),
|
: t('agent.msg.freeze_done', { action }),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
await api.put(`/admin/agents/${row.userId}`, {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
unfreezeDirectPlayers: freezeAgentForm.value.unfreezeDirectPlayers,
|
||||||
|
});
|
||||||
|
ElMessage.success(
|
||||||
|
freezeAgentForm.value.unfreezeDirectPlayers
|
||||||
|
? t('agent.msg.cascade_unfreeze_done')
|
||||||
|
: t('agent.msg.freeze_done', { action }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
freezeAgentVisible.value = false;
|
||||||
load();
|
load();
|
||||||
refreshExpandedParents();
|
refreshExpandedParents();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||||||
|
} finally {
|
||||||
|
freezeAgentLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,22 +1166,31 @@ function creditTypeLabel(type: string) {
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-settings-block">
|
<div class="list-settings-block">
|
||||||
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
|
<p class="list-settings-title">{{ t('agent.hierarchy.settings_title') }}</p>
|
||||||
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
|
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
|
||||||
<el-form inline size="small" class="settings-form">
|
<el-form inline size="small" class="settings-form">
|
||||||
<el-form-item :label="t('agent.suspend.freeze_direct_players')">
|
<el-form-item :label="t('agent.hierarchy.max_level')">
|
||||||
<el-switch
|
<el-input-number
|
||||||
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
|
v-model="hierarchySettings.maxAgentLevel"
|
||||||
:loading="agentSuspendSaving"
|
:min="0"
|
||||||
@change="saveAgentSuspendSettings"
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
:disabled="hierarchySaving"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('agent.suspend.block_player_login')">
|
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
|
||||||
<el-switch
|
<el-input-number
|
||||||
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
|
v-model="hierarchySettings.defaultSubAgentCreditRatio"
|
||||||
:loading="agentSuspendSaving"
|
:min="1"
|
||||||
@change="saveAgentSuspendSettings"
|
:max="100"
|
||||||
|
:step="5"
|
||||||
|
controls-position="right"
|
||||||
|
:disabled="hierarchySaving"
|
||||||
/>
|
/>
|
||||||
|
<span class="list-settings-unit">%</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@@ -1080,10 +1325,11 @@ function creditTypeLabel(type: string) {
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
<el-table-column :label="t('common.actions')" width="420" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
|
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
|
||||||
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
||||||
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||||
@@ -1110,7 +1356,7 @@ function creditTypeLabel(type: string) {
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- ─── Tab: 一级代理 ─── -->
|
<!-- ─── Tab: 一级代理 ─── -->
|
||||||
<el-tab-pane :label="`${t('user.type.tier1_agent')} (${tier1Total})`" name="tier1Agents">
|
<el-tab-pane :label="`${agentTierName(1)} (${tier1Total})`" name="tier1Agents">
|
||||||
<section class="list-panel agent-list-panel">
|
<section class="list-panel agent-list-panel">
|
||||||
<div class="list-panel-toolbar">
|
<div class="list-panel-toolbar">
|
||||||
<el-form inline class="list-chrome__grow">
|
<el-form inline class="list-chrome__grow">
|
||||||
@@ -1138,7 +1384,7 @@ function creditTypeLabel(type: string) {
|
|||||||
stripe
|
stripe
|
||||||
row-key="userId"
|
row-key="userId"
|
||||||
:row-class-name="expandableTableRowClassName"
|
:row-class-name="expandableTableRowClassName"
|
||||||
class="expandable-table"
|
class="expandable-table compact-agent-table"
|
||||||
@expand-change="onExpandChange"
|
@expand-change="onExpandChange"
|
||||||
@row-click="onTier1AgentRowClick"
|
@row-click="onTier1AgentRowClick"
|
||||||
>
|
>
|
||||||
@@ -1206,25 +1452,25 @@ function creditTypeLabel(type: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="userId" label="ID" width="72" />
|
<el-table-column prop="userId" label="ID" width="64" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="140" />
|
<el-table-column prop="username" :label="t('user.col.username')" width="100" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('common.status')" width="88">
|
<el-table-column :label="t('common.status')" width="72">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="level" :label="t('agent.col.level')" width="60" align="center">
|
<el-table-column prop="level" :label="t('agent.col.level')" width="52" align="center">
|
||||||
<template #default="{ row }">L{{ row.level }}</template>
|
<template #default="{ row }">L{{ row.level }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
<el-table-column :label="t('agent.col.credit')" width="132" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="72" align="center" />
|
||||||
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="80" align="center" />
|
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="72" align="center" />
|
||||||
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
|
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
|
||||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -1234,7 +1480,7 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ t('agent.create_sub') }}</el-button>
|
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
|
||||||
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
||||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1257,38 +1503,56 @@ function creditTypeLabel(type: string) {
|
|||||||
</section>
|
</section>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- ─── Tab: 二级代理 ─── -->
|
<!-- ─── Tab: L2+ 各级代理 ─── -->
|
||||||
<el-tab-pane :label="`${t('user.type.sub_agent')} (${tier2Total})`" name="tier2Agents">
|
<el-tab-pane
|
||||||
|
v-for="agentLevel in visibleSubAgentTabLevels"
|
||||||
|
:key="agentLevel"
|
||||||
|
:label="agentLevelTabLabel(agentLevel)"
|
||||||
|
:name="agentLevelTabName(agentLevel)"
|
||||||
|
>
|
||||||
<section class="list-panel agent-list-panel">
|
<section class="list-panel agent-list-panel">
|
||||||
<div class="list-panel-toolbar">
|
<div class="list-panel-toolbar">
|
||||||
<el-form inline class="list-chrome__grow">
|
<el-form inline class="list-chrome__grow">
|
||||||
<el-form-item :label="t('common.keyword')">
|
<el-form-item :label="t('common.keyword')">
|
||||||
<el-input v-model="tier2Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier2Agents" />
|
<el-input
|
||||||
|
v-model="ensureSubAgentState(agentLevel).keyword"
|
||||||
|
:placeholder="t('agent.filter.username_ph')"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('common.status')">
|
<el-form-item :label="t('common.status')">
|
||||||
<el-select v-model="tier2FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
<el-select
|
||||||
|
v-model="ensureSubAgentState(agentLevel).filterStatus"
|
||||||
|
:placeholder="t('common.all')"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="searchTier2Agents">{{ t('common.search') }}</el-button>
|
<el-button type="primary" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="list-chrome__actions">
|
<div class="list-chrome__actions">
|
||||||
<el-button type="primary" @click="openCreateSubAgentFromToolbar">{{ t('agent.create_sub_btn') }}</el-button>
|
<el-button type="primary" @click="openCreateSubAgentFromToolbar(agentLevel)">
|
||||||
|
{{ agentLevel === 2 ? t('agent.create_sub_btn') : t('agent.create_level_agent_btn', { level: lvlLabel(agentLevel) }) }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table
|
<el-table
|
||||||
ref="tier2AgentTableRef"
|
:ref="(el) => { subAgentTableRefs[agentLevel] = el as { toggleRowExpansion: (row: AgentRow) => void } | null }"
|
||||||
:data="tier2Agents"
|
:data="ensureSubAgentState(agentLevel).agents"
|
||||||
stripe
|
stripe
|
||||||
row-key="userId"
|
row-key="userId"
|
||||||
:row-class-name="expandableTableRowClassName"
|
:row-class-name="expandableTableRowClassName"
|
||||||
class="expandable-table"
|
class="expandable-table compact-agent-table"
|
||||||
@expand-change="onExpandChange"
|
@expand-change="onExpandChange"
|
||||||
@row-click="onTier2AgentRowClick"
|
@row-click="(row, column, event) => onSubAgentRowClick(agentLevel, row, column, event)"
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
@@ -1331,30 +1595,31 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="userId" label="ID" width="72" />
|
<el-table-column prop="userId" label="ID" width="64" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
<el-table-column prop="username" :label="t('user.col.username')" width="100" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('user.type.tier1_agent')" min-width="120">
|
<el-table-column :label="t('agent.col.parent_chain')" width="120" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.parentUsername ?? '—' }}</template>
|
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.status')" width="88">
|
<el-table-column :label="t('common.status')" width="72">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
|
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
<el-table-column :label="t('agent.col.credit')" width="132" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="72" align="center" />
|
||||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-btns" @click.stop>
|
<div class="action-btns" @click.stop>
|
||||||
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||||
|
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
|
||||||
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
||||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1364,14 +1629,14 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="tier2Page"
|
:current-page="ensureSubAgentState(agentLevel).page"
|
||||||
v-model:page-size="tier2PageSize"
|
:page-size="ensureSubAgentState(agentLevel).pageSize"
|
||||||
:total="tier2Total"
|
:total="ensureSubAgentState(agentLevel).total"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
background
|
background
|
||||||
@current-change="onTier2PageChange"
|
@current-change="(p) => onSubAgentPageChange(agentLevel, p)"
|
||||||
@size-change="onTier2SizeChange"
|
@size-change="(size) => onSubAgentSizeChange(agentLevel, size)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1380,92 +1645,203 @@ function creditTypeLabel(type: string) {
|
|||||||
|
|
||||||
<!-- ═══════════ DIALOGS ═══════════ -->
|
<!-- ═══════════ DIALOGS ═══════════ -->
|
||||||
|
|
||||||
|
<!-- ── Freeze / Unfreeze Agent ── -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="freezeAgentVisible"
|
||||||
|
:title="freezeAgentIsSuspend ? t('agent.freeze.confirm_freeze_title') : t('agent.unfreeze.confirm_title')"
|
||||||
|
width="480px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<template v-if="freezeAgentTarget">
|
||||||
|
<p class="freeze-agent-intro">
|
||||||
|
{{
|
||||||
|
freezeAgentIsSuspend
|
||||||
|
? t('agent.freeze.confirm_freeze_body', { name: freezeAgentTarget.username })
|
||||||
|
: t('agent.freeze.confirm_unfreeze_body', { name: freezeAgentTarget.username })
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div v-if="freezeAgentIsSuspend" class="freeze-agent-options">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="freezeAgentTarget.directPlayerCount > 0"
|
||||||
|
v-model="freezeAgentForm.freezeDirectPlayers"
|
||||||
|
>
|
||||||
|
{{ t('agent.freeze.opt_freeze_direct_players') }}
|
||||||
|
</el-checkbox>
|
||||||
|
<el-checkbox v-model="freezeAgentForm.blockDirectPlayerLogin">
|
||||||
|
{{ t('agent.freeze.opt_block_player_login') }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<div v-else class="freeze-agent-options">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="freezeAgentTarget.directPlayerCount > 0"
|
||||||
|
v-model="freezeAgentForm.unfreezeDirectPlayers"
|
||||||
|
>
|
||||||
|
{{ t('agent.unfreeze.opt_unfreeze_direct_players') }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="freezeAgentVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="freezeAgentIsSuspend ? 'warning' : 'primary'"
|
||||||
|
:loading="freezeAgentLoading"
|
||||||
|
@click="submitFreezeAgent"
|
||||||
|
>
|
||||||
|
{{ freezeAgentIsSuspend ? t('common.freeze') : t('common.unfreeze') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- ── Create (unified) ── -->
|
<!-- ── Create (unified) ── -->
|
||||||
<el-dialog v-model="createVisible" :title="createDialogTitle" width="520px" destroy-on-close class="create-account-dialog">
|
<el-dialog
|
||||||
<el-form label-width="100px">
|
v-model="createVisible"
|
||||||
|
:title="createDialogTitle"
|
||||||
|
width="460px"
|
||||||
|
align-center
|
||||||
|
destroy-on-close
|
||||||
|
class="create-account-dialog"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="createAccountMode === 2 && createParentAgentId && createParentLocked"
|
||||||
|
class="create-meta-bar"
|
||||||
|
>
|
||||||
|
<div class="create-meta-row">
|
||||||
|
<span class="create-meta-label">{{ t('agent.field.parent_agent') }}</span>
|
||||||
|
<span class="create-meta-value">{{ resolveCreateParentLabel(createParentAgentId) }}</span>
|
||||||
|
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="create-meta-row">
|
||||||
|
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
|
||||||
|
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="100px" size="small" class="compact-create-form">
|
||||||
|
<template v-if="createAccountMode === 2 && !createParentLocked">
|
||||||
|
<el-form-item :label="t('agent.field.parent_agent')" required>
|
||||||
|
<el-select
|
||||||
|
v-model="createParentAgentId"
|
||||||
|
:placeholder="createParentSelectPlaceholder"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="a in createParentSelectOptions"
|
||||||
|
:key="a.id"
|
||||||
|
:label="agentOptionLabel(a)"
|
||||||
|
:value="a.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div v-if="createTargetAgentLevel" class="field-hint">
|
||||||
|
{{ t('agent.hierarchy.create_level_hint', { n: lvlLabel(createTargetAgentLevel) }) }}
|
||||||
|
· {{ t('agent.hint.select_parent_for_level', { level: lvlLabel(createTargetAgentLevel - 1) }) }}
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="createParentAgentId" class="create-meta-bar create-meta-bar--inline">
|
||||||
|
<div class="create-meta-row">
|
||||||
|
<span class="create-meta-label">{{ t('agent.field.available_credit') }}</span>
|
||||||
|
<span class="create-meta-value c-green">{{ formatAmount(createParentAvailableCredit ?? '0') }}</span>
|
||||||
|
<el-tag v-if="createTargetAgentLevel" size="small" type="info">L{{ createTargetAgentLevel }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-form-item :label="t('user.col.username')" required>
|
<el-form-item :label="t('user.col.username')" required>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="createForm.username"
|
v-model="createForm.username"
|
||||||
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
|
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
|
||||||
/>
|
/>
|
||||||
<div v-if="createAccountMode === 0" class="field-hint">{{ t('user.hint.username_player') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="create-form-pair">
|
||||||
<el-form-item :label="t('user.field.password')" required>
|
<el-form-item :label="t('user.field.password')" required>
|
||||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 玩家:从代理行/展开区进入时锁定所属代理 -->
|
|
||||||
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
|
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
|
||||||
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
||||||
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 玩家:从玩家 Tab 进入时可选择一级/二级代理 -->
|
<el-form-item v-if="createAccountMode === 0 && !createParentLocked" :label="t('user.filter.agent')">
|
||||||
<template v-if="createAccountMode === 0 && !createParentLocked">
|
|
||||||
<el-form-item :label="t('user.filter.agent')">
|
|
||||||
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
||||||
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 二级代理:从一级代理行进入时锁定上级 -->
|
|
||||||
<el-form-item v-if="createAccountMode === 2 && createParentLocked" :label="t('user.type.tier1_agent')">
|
|
||||||
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
|
||||||
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 二级代理:从二级 Tab 进入时选择一级代理 -->
|
|
||||||
<template v-if="createAccountMode === 2 && !createParentLocked">
|
|
||||||
<el-form-item :label="t('user.type.tier1_agent')" required>
|
|
||||||
<el-select v-model="createParentAgentId" :placeholder="t('user.filter.agent_ph')" style="width: 100%">
|
|
||||||
<el-option v-for="a in tier1AgentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
|
||||||
</el-select>
|
|
||||||
<div class="field-hint">{{ t('agent.hint.sub_agent_parent') }}</div>
|
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 代理字段(一级 / 二级) -->
|
|
||||||
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
||||||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||||||
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
|
<el-input-number
|
||||||
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
v-model="createForm.creditLimit"
|
||||||
|
:min="0"
|
||||||
|
:max="createAccountMode === 2 ? createSubCreditMax : undefined"
|
||||||
|
:step="1000"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div v-if="createAccountMode === 2 && createParentAgentId" class="credit-quick-row">
|
||||||
|
<div class="field-hint">
|
||||||
|
{{ t('agent.hierarchy.create_credit_quick_hint', { amount: formatAmount(createParentAvailableCredit ?? '0') }) }}
|
||||||
|
</div>
|
||||||
|
<div class="credit-quick-btns">
|
||||||
|
<el-button
|
||||||
|
v-for="ratio in creditQuickRatios"
|
||||||
|
:key="ratio"
|
||||||
|
size="small"
|
||||||
|
:disabled="createSubCreditMax <= 0"
|
||||||
|
@click="applyCreateSubAgentCreditRatio(ratio)"
|
||||||
|
>
|
||||||
|
{{ ratio }}%
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||||
<el-input-number v-model="createForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
|
<el-input-number
|
||||||
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
|
v-model="createForm.cashbackRate"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.001"
|
||||||
|
:precision="4"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('agent.field.max_single_deposit')">
|
<el-form-item :label="t('agent.field.max_single_deposit')">
|
||||||
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
|
||||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
<el-form-item :label="t('agent.field.max_daily_deposit')">
|
||||||
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
|
||||||
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="createAccountMode === 0">
|
||||||
|
<div class="create-form-pair">
|
||||||
<el-form-item :label="t('user.field.phone')">
|
<el-form-item :label="t('user.field.phone')">
|
||||||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.email')">
|
<el-form-item :label="t('user.field.email')">
|
||||||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<!-- Player-only: initial balance -->
|
|
||||||
<template v-if="createAccountMode === 0">
|
|
||||||
<el-form-item :label="t('user.field.initial_balance')">
|
<el-form-item :label="t('user.field.initial_balance')">
|
||||||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||||||
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.deposit_remark')">
|
<el-form-item :label="t('user.field.deposit_remark')">
|
||||||
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="createAccountMode === 1">
|
||||||
|
<div class="create-form-pair">
|
||||||
|
<el-form-item :label="t('user.field.phone')">
|
||||||
|
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.field.email')">
|
||||||
|
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
@@ -1682,6 +2058,11 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
|
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
|
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
|
||||||
|
{{ t('user.action.view_wallet_ledger') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -1756,6 +2137,12 @@ function creditTypeLabel(type: string) {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<PlayerWalletLedgerDialog
|
||||||
|
v-model="walletLedgerVisible"
|
||||||
|
:player-id="walletLedgerPlayerId"
|
||||||
|
:player-username="walletLedgerPlayerUsername"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1792,6 +2179,18 @@ function creditTypeLabel(type: string) {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.freeze-agent-intro {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.freeze-agent-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Table toolbar ─── */
|
/* ─── Table toolbar ─── */
|
||||||
.list-panel-toolbar {
|
.list-panel-toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1827,7 +2226,6 @@ function creditTypeLabel(type: string) {
|
|||||||
padding: 12px 14px 14px;
|
padding: 12px 14px 14px;
|
||||||
background: #141414;
|
background: #141414;
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid #2a2a2a;
|
||||||
border-left: 3px solid rgba(47, 181, 106, 0.45);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.expand-loading {
|
.expand-loading {
|
||||||
@@ -1850,6 +2248,24 @@ function creditTypeLabel(type: string) {
|
|||||||
.expandable-table :deep(.row-expandable) {
|
.expandable-table :deep(.row-expandable) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.compact-agent-table :deep(.el-table__header .el-table__cell),
|
||||||
|
.compact-agent-table :deep(.el-table__body .el-table__cell) {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.compact-agent-table :deep(.el-table__header .cell) {
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.compact-agent-table :deep(.el-table__body .cell) {
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.compact-agent-table :deep(.el-tag) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.nested-table {
|
.nested-table {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
@@ -1891,9 +2307,6 @@ function creditTypeLabel(type: string) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.expandable-table :deep(.row-expandable) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.action-btns {
|
.action-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1909,6 +2322,71 @@ function creditTypeLabel(type: string) {
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||||||
|
:deep(.create-account-dialog .el-dialog__body) {
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 14px 20px 6px;
|
||||||
|
}
|
||||||
|
:deep(.create-account-dialog .el-dialog__footer) {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.compact-create-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.compact-create-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.credit-quick-row {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.credit-quick-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.create-meta-bar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
.create-meta-bar--inline {
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
.create-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.create-meta-row + .create-meta-row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.create-meta-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.create-meta-value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #ddd;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.create-meta-row .c-green {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.create-form-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 10px;
|
||||||
|
}
|
||||||
|
.c-green { color: #2fb56a; }
|
||||||
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||||||
.affiliation-tag {
|
.affiliation-tag {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1963,6 +2441,11 @@ function creditTypeLabel(type: string) {
|
|||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.list-settings-unit {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
.reset-db-alert { margin-bottom: 10px; }
|
.reset-db-alert { margin-bottom: 10px; }
|
||||||
.detail-block { margin-bottom: 16px; }
|
.detail-block { margin-bottom: 16px; }
|
||||||
.section-title {
|
.section-title {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useAdminLocale } from '../composables/useAdminLocale';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||||
|
|
||||||
const { t, locale, localeTag } = useAdminLocale();
|
const { t, locale, localeTag } = useAdminLocale();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface AgentRow {
|
|||||||
level: number;
|
level: number;
|
||||||
parentAgentId?: string | null;
|
parentAgentId?: string | null;
|
||||||
parentUsername?: string | null;
|
parentUsername?: string | null;
|
||||||
|
parentChain?: string[];
|
||||||
|
parentChainLabel?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
creditLimit: string;
|
creditLimit: string;
|
||||||
usedCredit: string;
|
usedCredit: string;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import type { TableInstance } from 'element-plus';
|
import type { TableInstance } from 'element-plus';
|
||||||
import WalletTransferContext from '../../components/WalletTransferContext.vue';
|
import WalletTransferContext from '../../components/WalletTransferContext.vue';
|
||||||
import AgentCreditContext from '../../components/AgentCreditContext.vue';
|
import AgentCreditContext from '../../components/AgentCreditContext.vue';
|
||||||
|
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
|
||||||
import {
|
import {
|
||||||
depositAmountCap,
|
depositAmountCap,
|
||||||
parsePlayerAvailable,
|
parsePlayerAvailable,
|
||||||
@@ -45,11 +46,16 @@ import {
|
|||||||
const { t, localeTag } = useAdminLocale();
|
const { t, localeTag } = useAdminLocale();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
/* L1 agents can manage sub-agents; L2 cannot */
|
const profile = ref<{
|
||||||
const isTier1 = computed(() => auth.isTier1Agent.value);
|
creditLimit?: string;
|
||||||
|
usedCredit?: string;
|
||||||
|
availableCredit?: string;
|
||||||
|
canManageSubAgents?: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
/* ─── Credit profile ─── */
|
const canManageSubAgents = computed(
|
||||||
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
|
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
|
||||||
|
);
|
||||||
|
|
||||||
/* ─── Top-level tab: players | subAgents ─── */
|
/* ─── Top-level tab: players | subAgents ─── */
|
||||||
const activeTab = ref('players');
|
const activeTab = ref('players');
|
||||||
@@ -161,7 +167,7 @@ const transferAmountCapError = computed(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
await loadPlayers();
|
await loadPlayers();
|
||||||
if (isTier1.value) {
|
if (canManageSubAgents.value) {
|
||||||
await loadSubAgents();
|
await loadSubAgents();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -183,6 +189,16 @@ async function loadPlayers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const walletLedgerVisible = ref(false);
|
||||||
|
const walletLedgerPlayerId = ref('');
|
||||||
|
const walletLedgerPlayerUsername = ref<string | null>(null);
|
||||||
|
|
||||||
|
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
|
||||||
|
walletLedgerPlayerId.value = playerId;
|
||||||
|
walletLedgerPlayerUsername.value = playerUsername ?? null;
|
||||||
|
walletLedgerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSubAgents() {
|
async function loadSubAgents() {
|
||||||
loadingAgents.value = true;
|
loadingAgents.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -622,10 +638,11 @@ function statusTagType(s: string) {
|
|||||||
<el-table-column :label="t('user.col.created')" min-width="148">
|
<el-table-column :label="t('user.col.created')" min-width="148">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.actions')" width="300" align="center" fixed="right">
|
<el-table-column :label="t('common.actions')" width="380" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
||||||
|
<el-button size="small" type="primary" link @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
|
||||||
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
||||||
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||||
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreeze(row)">{{ t('common.freeze') }}</el-button>
|
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreeze(row)">{{ t('common.freeze') }}</el-button>
|
||||||
@@ -637,7 +654,7 @@ function statusTagType(s: string) {
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- ══════ Tab: 下级代理 (仅一级代理可见) ══════ -->
|
<!-- ══════ Tab: 下级代理 (仅一级代理可见) ══════ -->
|
||||||
<el-tab-pane v-if="isTier1" :label="`${t('nav.subAgents')} (${subAgents.length})`" name="subAgents">
|
<el-tab-pane v-if="canManageSubAgents" :label="`${t('nav.subAgents')} (${subAgents.length})`" name="subAgents">
|
||||||
<div class="inner-toolbar">
|
<div class="inner-toolbar">
|
||||||
<el-form inline size="small" style="flex: 1">
|
<el-form inline size="small" style="flex: 1">
|
||||||
<el-form-item :label="t('common.search')">
|
<el-form-item :label="t('common.search')">
|
||||||
@@ -955,6 +972,12 @@ function statusTagType(s: string) {
|
|||||||
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
|
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<PlayerWalletLedgerDialog
|
||||||
|
v-model="walletLedgerVisible"
|
||||||
|
:player-id="walletLedgerPlayerId"
|
||||||
|
:player-username="walletLedgerPlayerUsername"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export interface AgentPlayerEditForm {
|
|||||||
export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
|
export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
password: 'Player@123',
|
password: '',
|
||||||
confirmPassword: 'Player@123',
|
confirmPassword: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
initialDeposit: 0,
|
initialDeposit: 0,
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export interface AgentSubAgentCreateForm {
|
|||||||
export function emptyAgentSubAgentCreateForm(): AgentSubAgentCreateForm {
|
export function emptyAgentSubAgentCreateForm(): AgentSubAgentCreateForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
password: 'Agent@123',
|
password: '',
|
||||||
confirmPassword: 'Agent@123',
|
confirmPassword: '',
|
||||||
creditLimit: 10000,
|
creditLimit: 10000,
|
||||||
cashbackRate: 0,
|
cashbackRate: 0,
|
||||||
maxSingleDeposit: 0,
|
maxSingleDeposit: 0,
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export function formatPlayerAffiliationLabel(
|
|||||||
export function emptyPlayerCreateForm(): PlayerCreateForm {
|
export function emptyPlayerCreateForm(): PlayerCreateForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
password: 'Player@123',
|
password: '',
|
||||||
confirmPassword: 'Player@123',
|
confirmPassword: '',
|
||||||
parentId: '',
|
parentId: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "agent_profiles" ADD COLUMN "block_direct_player_login" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -131,6 +131,7 @@ model AgentProfile {
|
|||||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||||
|
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
IsIn,
|
IsIn,
|
||||||
Min,
|
Min,
|
||||||
|
Max,
|
||||||
Equals,
|
Equals,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -257,6 +258,19 @@ class AgentSuspendSettingsDto {
|
|||||||
suspendBlockPlayerLogin?: boolean;
|
suspendBlockPlayerLogin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AgentHierarchySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxAgentLevel?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
defaultSubAgentCreditRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ResetDatabaseDto {
|
class ResetDatabaseDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Equals('RESET')
|
@Equals('RESET')
|
||||||
@@ -340,6 +354,16 @@ class UpdateAgentAdminDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
freezeDirectPlayers?: boolean;
|
freezeDirectPlayers?: boolean;
|
||||||
|
|
||||||
|
/** 冻结时是否禁止直属玩家登录 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
blockDirectPlayerLogin?: boolean;
|
||||||
|
|
||||||
|
/** 解冻时是否级联解冻直属玩家 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
unfreezeDirectPlayers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DepositDto {
|
class DepositDto {
|
||||||
@@ -945,6 +969,30 @@ export class AdminController {
|
|||||||
return jsonResponse(settings);
|
return jsonResponse(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('agents/settings/hierarchy')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
async getAgentHierarchySettings() {
|
||||||
|
const settings = await this.systemConfig.getAgentHierarchySettings();
|
||||||
|
return jsonResponse(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('agents/settings/hierarchy')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
async updateAgentHierarchySettings(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Body() dto: AgentHierarchySettingsDto,
|
||||||
|
) {
|
||||||
|
const settings = await this.systemConfig.updateAgentHierarchySettings(dto);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'UPDATE_AGENT_HIERARCHY_SETTINGS',
|
||||||
|
module: 'AGENTS',
|
||||||
|
afterData: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
return jsonResponse(settings);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('settings/betting-limits')
|
@Get('settings/betting-limits')
|
||||||
@RequirePermissions(P.settings)
|
@RequirePermissions(P.settings)
|
||||||
async getBettingLimits() {
|
async getBettingLimits() {
|
||||||
@@ -1122,6 +1170,13 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('agents/level-counts')
|
||||||
|
@RequirePermissions(P.agentsView)
|
||||||
|
async getAgentLevelCounts() {
|
||||||
|
const counts = await this.agents.countAgentsByLevel();
|
||||||
|
return jsonResponse(counts);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('agents')
|
@Get('agents')
|
||||||
@RequirePermissions(P.agentsView)
|
@RequirePermissions(P.agentsView)
|
||||||
async listAgents(
|
async listAgents(
|
||||||
@@ -1130,15 +1185,21 @@ export class AdminController {
|
|||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('level') level?: string,
|
@Query('level') level?: string,
|
||||||
|
@Query('minLevel') minLevel?: string,
|
||||||
|
@Query('maxLevel') maxLevel?: string,
|
||||||
@Query('parentAgentId') parentAgentId?: string,
|
@Query('parentAgentId') parentAgentId?: string,
|
||||||
) {
|
) {
|
||||||
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
|
const parsedLevel = level != null && level !== '' ? parseInt(level, 10) : undefined;
|
||||||
|
const parsedMinLevel = minLevel != null && minLevel !== '' ? parseInt(minLevel, 10) : undefined;
|
||||||
|
const parsedMaxLevel = maxLevel != null && maxLevel !== '' ? parseInt(maxLevel, 10) : undefined;
|
||||||
const result = await this.agents.listAgentsAdmin({
|
const result = await this.agents.listAgentsAdmin({
|
||||||
page: page ? parseInt(page, 10) : 1,
|
page: page ? parseInt(page, 10) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||||
keyword,
|
keyword,
|
||||||
status,
|
status,
|
||||||
level: parsedLevel,
|
level: Number.isFinite(parsedLevel) ? parsedLevel : undefined,
|
||||||
|
minLevel: Number.isFinite(parsedMinLevel) ? parsedMinLevel : undefined,
|
||||||
|
maxLevel: Number.isFinite(parsedMaxLevel) ? parsedMaxLevel : undefined,
|
||||||
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
||||||
});
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
@@ -1270,9 +1331,33 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('wallet/transactions')
|
@Get('wallet/transactions')
|
||||||
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
|
@RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports)
|
||||||
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
|
async walletTransactions(
|
||||||
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('playerId') playerId?: string,
|
||||||
|
@Query('parentAgentId') parentAgentId?: string,
|
||||||
|
@Query('parentAgentKeyword') parentAgentKeyword?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('operatorKeyword') operatorKeyword?: string,
|
||||||
|
@Query('transactionType') transactionType?: string,
|
||||||
|
@Query('typeCategory') typeCategory?: string,
|
||||||
|
@Query('dateFrom') dateFrom?: string,
|
||||||
|
@Query('dateTo') dateTo?: string,
|
||||||
|
) {
|
||||||
|
const result = await this.wallet.listWalletTransactionsAdmin({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
|
||||||
|
playerId: playerId ? BigInt(playerId) : undefined,
|
||||||
|
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
||||||
|
parentAgentKeyword,
|
||||||
|
keyword,
|
||||||
|
operatorKeyword,
|
||||||
|
transactionType,
|
||||||
|
typeCategory,
|
||||||
|
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
|
||||||
|
dateTo: dateTo ? new Date(dateTo) : undefined,
|
||||||
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
|
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
|
||||||
import { CurrentUser } from '../../shared/common/decorators';
|
import { CurrentUser } from '../../shared/common/decorators';
|
||||||
import { jsonResponse } from '../../shared/common/filters';
|
import { jsonResponse } from '../../shared/common/filters';
|
||||||
|
import { appForbidden } from '../../shared/common/app-error';
|
||||||
import { AgentsService } from '../../domains/agent/agents.service';
|
import { AgentsService } from '../../domains/agent/agents.service';
|
||||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||||
import { BetsService } from '../../domains/betting/bets.service';
|
import { BetsService } from '../../domains/betting/bets.service';
|
||||||
@@ -131,6 +132,14 @@ class UpdateSubAgentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
freezeDirectPlayers?: boolean;
|
freezeDirectPlayers?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
blockDirectPlayerLogin?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
unfreezeDirectPlayers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Agent Portal')
|
@ApiTags('Agent Portal')
|
||||||
@@ -145,10 +154,20 @@ export class AgentPortalController {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private async canManageSubAgents(level: number) {
|
||||||
|
const maxLevel = await this.agents.getMaxAgentLevel();
|
||||||
|
return this.agents.canCreateSubAgent(level, maxLevel);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
async profile(@CurrentUser('id') agentId: bigint) {
|
async profile(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
|
||||||
const profile = await this.agents.getProfile(agentId);
|
const profile = await this.agents.getProfile(agentId);
|
||||||
return jsonResponse(profile);
|
const maxLevel = await this.agents.getMaxAgentLevel();
|
||||||
|
return jsonResponse({
|
||||||
|
...profile,
|
||||||
|
maxAgentLevel: maxLevel,
|
||||||
|
canManageSubAgents: this.agents.canCreateSubAgent(level, maxLevel),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('players')
|
@Get('players')
|
||||||
@@ -218,7 +237,8 @@ export class AgentPortalController {
|
|||||||
|
|
||||||
@Get('agents')
|
@Get('agents')
|
||||||
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
|
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
|
||||||
if (level !== 1) {
|
const maxLevel = await this.agents.getMaxAgentLevel();
|
||||||
|
if (!this.agents.canCreateSubAgent(level, maxLevel)) {
|
||||||
return jsonResponse([]);
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
const agents = await this.agents.listChildAgentsSummary(agentId);
|
const agents = await this.agents.listChildAgentsSummary(agentId);
|
||||||
@@ -227,13 +247,14 @@ export class AgentPortalController {
|
|||||||
|
|
||||||
@Post('agents')
|
@Post('agents')
|
||||||
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
|
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
|
||||||
if (level !== 1) {
|
const maxLevel = await this.agents.getMaxAgentLevel();
|
||||||
return jsonResponse(null, 'Only level 1 agents can create sub-agents');
|
if (!this.agents.canCreateSubAgent(level, maxLevel)) {
|
||||||
|
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
|
||||||
}
|
}
|
||||||
const user = await this.agents.createAgent(agentId, {
|
const user = await this.agents.createAgent(agentId, {
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password: dto.password,
|
password: dto.password,
|
||||||
level: 2,
|
level: level + 1,
|
||||||
parentAgentId: agentId,
|
parentAgentId: agentId,
|
||||||
creditLimit: dto.creditLimit,
|
creditLimit: dto.creditLimit,
|
||||||
cashbackRate: dto.cashbackRate,
|
cashbackRate: dto.cashbackRate,
|
||||||
@@ -281,8 +302,8 @@ export class AgentPortalController {
|
|||||||
@CurrentUser('agentLevel') level: number,
|
@CurrentUser('agentLevel') level: number,
|
||||||
@Param('id') subAgentId: string,
|
@Param('id') subAgentId: string,
|
||||||
) {
|
) {
|
||||||
if (level !== 1) {
|
if (!(await this.canManageSubAgents(level))) {
|
||||||
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
|
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
|
||||||
}
|
}
|
||||||
const detail = await this.agents.getSubAgentForParent(agentId, BigInt(subAgentId));
|
const detail = await this.agents.getSubAgentForParent(agentId, BigInt(subAgentId));
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
@@ -295,8 +316,8 @@ export class AgentPortalController {
|
|||||||
@Param('id') subAgentId: string,
|
@Param('id') subAgentId: string,
|
||||||
@Body() dto: UpdateSubAgentDto,
|
@Body() dto: UpdateSubAgentDto,
|
||||||
) {
|
) {
|
||||||
if (level !== 1) {
|
if (!(await this.canManageSubAgents(level))) {
|
||||||
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
|
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
|
||||||
}
|
}
|
||||||
const detail = await this.agents.updateSubAgentForParent(agentId, BigInt(subAgentId), dto);
|
const detail = await this.agents.updateSubAgentForParent(agentId, BigInt(subAgentId), dto);
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
@@ -308,7 +329,7 @@ export class AgentPortalController {
|
|||||||
@CurrentUser('agentLevel') level: number,
|
@CurrentUser('agentLevel') level: number,
|
||||||
@Param('id') subAgentId: string,
|
@Param('id') subAgentId: string,
|
||||||
) {
|
) {
|
||||||
if (level !== 1) {
|
if (!(await this.canManageSubAgents(level))) {
|
||||||
return jsonResponse([]);
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
|
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
|
||||||
@@ -448,4 +469,56 @@ export class AgentPortalController {
|
|||||||
});
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('wallet/ledger-transactions')
|
||||||
|
async walletLedgerTransactions(
|
||||||
|
@CurrentUser('id') agentId: bigint,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('playerId') playerId?: string,
|
||||||
|
@Query('parentAgentId') parentAgentId?: string,
|
||||||
|
@Query('parentAgentKeyword') parentAgentKeyword?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('operatorKeyword') operatorKeyword?: string,
|
||||||
|
@Query('transactionType') transactionType?: string,
|
||||||
|
@Query('typeCategory') typeCategory?: string,
|
||||||
|
@Query('dateFrom') dateFrom?: string,
|
||||||
|
@Query('dateTo') dateTo?: string,
|
||||||
|
) {
|
||||||
|
const scopedParentAgentIds = await this.agents.getSubtreeAgentIds(agentId);
|
||||||
|
const parsedParentAgentId = parentAgentId ? BigInt(parentAgentId) : undefined;
|
||||||
|
if (
|
||||||
|
parsedParentAgentId &&
|
||||||
|
!scopedParentAgentIds.some((id) => id === parsedParentAgentId)
|
||||||
|
) {
|
||||||
|
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
}
|
||||||
|
if (playerId) {
|
||||||
|
const player = await this.prisma.user.findFirst({
|
||||||
|
where: { id: BigInt(playerId), userType: 'PLAYER', deletedAt: null },
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!player?.parentId ||
|
||||||
|
!scopedParentAgentIds.some((id) => id === player.parentId)
|
||||||
|
) {
|
||||||
|
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await this.wallet.listWalletTransactionsAdmin({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
|
||||||
|
playerId: playerId ? BigInt(playerId) : undefined,
|
||||||
|
parentAgentId: parsedParentAgentId,
|
||||||
|
parentAgentKeyword,
|
||||||
|
scopedParentAgentIds,
|
||||||
|
keyword,
|
||||||
|
operatorKeyword,
|
||||||
|
transactionType,
|
||||||
|
typeCategory,
|
||||||
|
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
|
||||||
|
dateTo: dateTo ? new Date(dateTo) : undefined,
|
||||||
|
});
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,96 @@ export class AgentsService {
|
|||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getMaxAgentLevel(): Promise<number> {
|
||||||
|
const settings = await this.systemConfig.getAgentHierarchySettings();
|
||||||
|
return settings.maxAgentLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
canCreateSubAgent(agentLevel: number, maxLevel: number): boolean {
|
||||||
|
if (maxLevel === 0) return true;
|
||||||
|
return agentLevel < maxLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||||
|
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||||
|
const pending = new Set<bigint>();
|
||||||
|
|
||||||
|
for (const id of parentAgentIds) {
|
||||||
|
if (id) pending.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pending.size > 0) {
|
||||||
|
const batch = [...pending];
|
||||||
|
pending.clear();
|
||||||
|
const profiles = await this.prisma.agentProfile.findMany({
|
||||||
|
where: { userId: { in: batch } },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
parentAgentId: true,
|
||||||
|
user: { select: { username: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const profile of profiles) {
|
||||||
|
cache.set(profile.userId.toString(), {
|
||||||
|
username: profile.user.username,
|
||||||
|
parentAgentId: profile.parentAgentId,
|
||||||
|
});
|
||||||
|
if (profile.parentAgentId && !cache.has(profile.parentAgentId.toString())) {
|
||||||
|
pending.add(profile.parentAgentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (startId: bigint | null | undefined): string[] => {
|
||||||
|
const chain: string[] = [];
|
||||||
|
let cur = startId ?? null;
|
||||||
|
while (cur) {
|
||||||
|
const hit = cache.get(cur.toString());
|
||||||
|
if (!hit) break;
|
||||||
|
chain.unshift(hit.username);
|
||||||
|
cur = hit.parentAgentId;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const id of parentAgentIds) {
|
||||||
|
if (id) map.set(id.toString(), build(id));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
|
||||||
|
if (!Number.isInteger(level) || level < 1) {
|
||||||
|
throw appBadRequest('AGENT_LEVEL_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLevel = await this.getMaxAgentLevel();
|
||||||
|
if (maxLevel > 0 && level > maxLevel) {
|
||||||
|
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 1) {
|
||||||
|
if (parentAgentId) throw appBadRequest('AGENT_LEVEL_ROOT_INVALID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentAgentId) {
|
||||||
|
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId: parentAgentId },
|
||||||
|
});
|
||||||
|
if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||||||
|
if (parent.level !== level - 1) {
|
||||||
|
throw appBadRequest('AGENT_PARENT_LEVEL_MISMATCH');
|
||||||
|
}
|
||||||
|
if (maxLevel > 0 && !this.canCreateSubAgent(parent.level, maxLevel)) {
|
||||||
|
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getProfile(agentId: bigint) {
|
async getProfile(agentId: bigint) {
|
||||||
const profile = await this.prisma.agentProfile.findUnique({
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
where: { userId: agentId },
|
where: { userId: agentId },
|
||||||
@@ -560,7 +650,9 @@ export class AgentsService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
level?: 1 | 2;
|
level?: number;
|
||||||
|
minLevel?: number;
|
||||||
|
maxLevel?: number;
|
||||||
parentAgentId?: bigint;
|
parentAgentId?: bigint;
|
||||||
}) {
|
}) {
|
||||||
const page = Math.max(1, params?.page ?? 1);
|
const page = Math.max(1, params?.page ?? 1);
|
||||||
@@ -568,10 +660,13 @@ export class AgentsService {
|
|||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.AgentProfileWhereInput = {};
|
const where: Prisma.AgentProfileWhereInput = {};
|
||||||
if (params?.level === 2) {
|
if (params?.level != null) {
|
||||||
where.level = 2;
|
where.level = params.level;
|
||||||
} else if (params?.level === 1) {
|
} else if (params?.minLevel != null || params?.maxLevel != null) {
|
||||||
where.level = 1;
|
const levelFilter: { gte?: number; lte?: number } = {};
|
||||||
|
if (params.minLevel != null) levelFilter.gte = params.minLevel;
|
||||||
|
if (params.maxLevel != null) levelFilter.lte = params.maxLevel;
|
||||||
|
where.level = levelFilter;
|
||||||
} else if (params?.parentAgentId !== undefined) {
|
} else if (params?.parentAgentId !== undefined) {
|
||||||
where.parentAgentId = params.parentAgentId;
|
where.parentAgentId = params.parentAgentId;
|
||||||
} else {
|
} else {
|
||||||
@@ -644,9 +739,15 @@ export class AgentsService {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
||||||
|
const parentChainMap = await this.buildAgentAncestorChainMap(
|
||||||
|
profiles.map((p) => p.parentAgentId),
|
||||||
|
);
|
||||||
|
|
||||||
const items = profiles.map((p) => {
|
const items = profiles.map((p) => {
|
||||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||||
|
const parentChain = p.parentAgentId
|
||||||
|
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
|
||||||
|
: [];
|
||||||
return {
|
return {
|
||||||
id: p.id.toString(),
|
id: p.id.toString(),
|
||||||
userId: p.userId.toString(),
|
userId: p.userId.toString(),
|
||||||
@@ -658,6 +759,8 @@ export class AgentsService {
|
|||||||
parentUsername: p.parentAgentId
|
parentUsername: p.parentAgentId
|
||||||
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
|
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
|
||||||
: null,
|
: null,
|
||||||
|
parentChain,
|
||||||
|
parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
|
||||||
creditLimit: p.creditLimit.toString(),
|
creditLimit: p.creditLimit.toString(),
|
||||||
usedCredit: p.usedCredit.toString(),
|
usedCredit: p.usedCredit.toString(),
|
||||||
availableCredit: available.toString(),
|
availableCredit: available.toString(),
|
||||||
@@ -679,6 +782,19 @@ export class AgentsService {
|
|||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countAgentsByLevel(): Promise<Record<number, number>> {
|
||||||
|
const groups = await this.prisma.agentProfile.groupBy({
|
||||||
|
by: ['level'],
|
||||||
|
where: { user: { deletedAt: null } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const out: Record<number, number> = {};
|
||||||
|
for (const g of groups) {
|
||||||
|
out[g.level] = g._count._all;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async getAgentAdminDetail(agentId: bigint) {
|
async getAgentAdminDetail(agentId: bigint) {
|
||||||
const profile = await this.prisma.agentProfile.findUnique({
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
where: { userId: agentId },
|
where: { userId: agentId },
|
||||||
@@ -902,6 +1018,8 @@ export class AgentsService {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
freezeDirectPlayers?: boolean;
|
freezeDirectPlayers?: boolean;
|
||||||
|
blockDirectPlayerLogin?: boolean;
|
||||||
|
unfreezeDirectPlayers?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const profile = await this.prisma.agentProfile.findUnique({
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
@@ -945,8 +1063,15 @@ export class AgentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle status change (with optional cascade freeze)
|
// Handle status change (per-action cascade freeze / login block)
|
||||||
if (data.status) {
|
if (data.status) {
|
||||||
|
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
|
||||||
|
if (data.status === 'SUSPENDED') {
|
||||||
|
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
|
||||||
|
} else if (data.status === 'ACTIVE') {
|
||||||
|
profilePatch.blockDirectPlayerLogin = false;
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.user.update({
|
this.prisma.user.update({
|
||||||
where: { id: agentId },
|
where: { id: agentId },
|
||||||
@@ -954,22 +1079,23 @@ export class AgentsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.agentProfile.update({
|
this.prisma.agentProfile.update({
|
||||||
where: { userId: agentId },
|
where: { userId: agentId },
|
||||||
data: { status: data.status },
|
data: profilePatch,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 级联冻结:需后台开启且管理员/操作方显式勾选(MVP 默认不冻结玩家)
|
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
|
||||||
const suspendSettings = await this.systemConfig.getAgentSuspendSettings();
|
|
||||||
if (
|
|
||||||
data.status === 'SUSPENDED' &&
|
|
||||||
data.freezeDirectPlayers &&
|
|
||||||
suspendSettings.suspendFreezeDirectPlayers
|
|
||||||
) {
|
|
||||||
await this.prisma.user.updateMany({
|
await this.prisma.user.updateMany({
|
||||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||||
data: { status: 'SUSPENDED' },
|
data: { status: 'SUSPENDED' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'ACTIVE' && data.unfreezeDirectPlayers) {
|
||||||
|
await this.prisma.user.updateMany({
|
||||||
|
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null, status: 'SUSPENDED' },
|
||||||
|
data: { status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.locale) {
|
if (data.locale) {
|
||||||
@@ -1167,12 +1293,7 @@ export class AgentsService {
|
|||||||
maxDailyDeposit?: number | null;
|
maxDailyDeposit?: number | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (data.level !== 1 && data.level !== 2) {
|
await this.validateAgentLevel(data.level, data.parentAgentId);
|
||||||
throw appBadRequest('AGENT_LEVEL_INVALID');
|
|
||||||
}
|
|
||||||
if (data.level === 2 && !data.parentAgentId) {
|
|
||||||
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.parentAgentId) {
|
if (data.parentAgentId) {
|
||||||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||||||
@@ -1300,10 +1421,17 @@ export class AgentsService {
|
|||||||
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
|
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
|
||||||
}
|
}
|
||||||
const parentAgentId = data.parentAgentId ?? data.parentId;
|
const parentAgentId = data.parentAgentId ?? data.parentId;
|
||||||
|
if (parentAgentId == null) {
|
||||||
|
throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
|
||||||
|
}
|
||||||
|
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId: parentAgentId },
|
||||||
|
});
|
||||||
|
if (!parentProfile) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||||||
return this.createAgent(operatorId, {
|
return this.createAgent(operatorId, {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
level: 2,
|
level: parentProfile.level + 1,
|
||||||
parentAgentId,
|
parentAgentId,
|
||||||
creditLimit: data.creditLimit ?? 0,
|
creditLimit: data.creditLimit ?? 0,
|
||||||
cashbackRate: data.cashbackRate ?? 0,
|
cashbackRate: data.cashbackRate ?? 0,
|
||||||
@@ -1470,11 +1598,12 @@ export class AgentsService {
|
|||||||
email?: string;
|
email?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
freezeDirectPlayers?: boolean;
|
freezeDirectPlayers?: boolean;
|
||||||
|
blockDirectPlayerLogin?: boolean;
|
||||||
|
unfreezeDirectPlayers?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||||
const { freezeDirectPlayers: _ignored, ...safeData } = data;
|
return this.updateAgentAdmin(subAgentId, data);
|
||||||
return this.updateAgentAdmin(subAgentId, safeData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSubtreeAgentIds(agentId: bigint) {
|
async getSubtreeAgentIds(agentId: bigint) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { LoginDto, ChangePasswordDto } from './auth.dto';
|
import { LoginDto, ChangePasswordDto } from './auth.dto';
|
||||||
import { Public, CurrentUser } from '../../shared/common/decorators';
|
import { Public, CurrentUser } from '../../shared/common/decorators';
|
||||||
import { JwtAuthGuard } from './guards';
|
import { JwtAuthGuard } from './guards';
|
||||||
@@ -9,7 +10,10 @@ import { jsonResponse } from '../../shared/common/filters';
|
|||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private auth: AuthService) {}
|
constructor(
|
||||||
|
private auth: AuthService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('player/auth/login')
|
@Post('player/auth/login')
|
||||||
@@ -50,13 +54,25 @@ export class AuthController {
|
|||||||
@CurrentUser('role') role: string | undefined,
|
@CurrentUser('role') role: string | undefined,
|
||||||
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
|
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
|
||||||
) {
|
) {
|
||||||
|
const level = userType === 'AGENT' ? agentLevel ?? null : null;
|
||||||
|
let maxAgentLevel: number | null = null;
|
||||||
|
let canManageSubAgents = false;
|
||||||
|
if (userType === 'AGENT' && level != null && level > 0) {
|
||||||
|
const hierarchy = await this.systemConfig.getAgentHierarchySettings();
|
||||||
|
maxAgentLevel = hierarchy.maxAgentLevel;
|
||||||
|
canManageSubAgents =
|
||||||
|
maxAgentLevel === 0 || level < maxAgentLevel;
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: userId.toString(),
|
id: userId.toString(),
|
||||||
username,
|
username,
|
||||||
userType,
|
userType,
|
||||||
locale,
|
locale,
|
||||||
role,
|
role,
|
||||||
agentLevel: userType === 'AGENT' ? agentLevel ?? null : null,
|
agentLevel: level,
|
||||||
|
maxAgentLevel,
|
||||||
|
canManageSubAgents,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
|
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SystemConfigModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -62,17 +62,22 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (portal === 'player' && user.parentId) {
|
if (portal === 'player' && user.parentId) {
|
||||||
const agentSettings = await this.systemConfig.getAgentSuspendSettings();
|
|
||||||
if (agentSettings.suspendBlockPlayerLogin) {
|
|
||||||
const parentAgent = await this.prisma.user.findUnique({
|
const parentAgent = await this.prisma.user.findUnique({
|
||||||
where: { id: user.parentId },
|
where: { id: user.parentId },
|
||||||
select: { userType: true, status: true },
|
select: {
|
||||||
|
userType: true,
|
||||||
|
status: true,
|
||||||
|
agentProfile: { select: { blockDirectPlayerLogin: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (parentAgent?.userType === 'AGENT' && parentAgent.status !== 'ACTIVE') {
|
if (
|
||||||
|
parentAgent?.userType === 'AGENT' &&
|
||||||
|
parentAgent.status !== 'ACTIVE' &&
|
||||||
|
parentAgent.agentProfile?.blockDirectPlayerLogin
|
||||||
|
) {
|
||||||
throw appForbidden('PARENT_AGENT_SUSPENDED');
|
throw appForbidden('PARENT_AGENT_SUSPENDED');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
|
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
|
||||||
throw appForbidden('ACCOUNT_LOCKED');
|
throw appForbidden('ACCOUNT_LOCKED');
|
||||||
|
|||||||
@@ -25,14 +25,61 @@ export class UsersService {
|
|||||||
parent?: {
|
parent?: {
|
||||||
username: string;
|
username: string;
|
||||||
agentLevel: number | null;
|
agentLevel: number | null;
|
||||||
parent?: { username: string; agentLevel: number | null } | null;
|
parent?: { username: string; agentLevel: number | null; parent?: unknown } | null;
|
||||||
} | null,
|
} | null,
|
||||||
|
chain?: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
|
if (chain?.length) return chain;
|
||||||
if (!parent) return [];
|
if (!parent) return [];
|
||||||
if (parent.agentLevel === 2 && parent.parent?.username) {
|
const ancestors: string[] = [];
|
||||||
return [parent.parent.username, parent.username];
|
let cur: typeof parent | null | undefined = parent;
|
||||||
|
while (cur) {
|
||||||
|
ancestors.unshift(cur.username);
|
||||||
|
cur = cur.parent as typeof parent | null | undefined;
|
||||||
}
|
}
|
||||||
return [parent.username];
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAffiliationChainMap(parentIds: (bigint | null | undefined)[]) {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
const pending = new Set<bigint>();
|
||||||
|
const cache = new Map<string, { username: string; parentId: bigint | null }>();
|
||||||
|
|
||||||
|
for (const pid of parentIds) {
|
||||||
|
if (pid) pending.add(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pending.size > 0) {
|
||||||
|
const batch = [...pending];
|
||||||
|
pending.clear();
|
||||||
|
const agents = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: batch }, userType: 'AGENT', deletedAt: null },
|
||||||
|
select: { id: true, username: true, parentId: true },
|
||||||
|
});
|
||||||
|
for (const agent of agents) {
|
||||||
|
cache.set(agent.id.toString(), { username: agent.username, parentId: agent.parentId });
|
||||||
|
if (agent.parentId && !cache.has(agent.parentId.toString())) {
|
||||||
|
pending.add(agent.parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (startId: bigint | null | undefined): string[] => {
|
||||||
|
const chain: string[] = [];
|
||||||
|
let cur = startId ?? null;
|
||||||
|
while (cur) {
|
||||||
|
const hit = cache.get(cur.toString());
|
||||||
|
if (!hit) break;
|
||||||
|
chain.unshift(hit.username);
|
||||||
|
cur = hit.parentId;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const pid of parentIds) {
|
||||||
|
if (pid) map.set(pid.toString(), build(pid));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatPlayerRow(
|
private formatPlayerRow(
|
||||||
@@ -58,8 +105,9 @@ export class UsersService {
|
|||||||
auth?: { lastLoginAt: Date | null } | null;
|
auth?: { lastLoginAt: Date | null } | null;
|
||||||
},
|
},
|
||||||
bet?: { count: number; totalStake: string; totalReturn: string },
|
bet?: { count: number; totalStake: string; totalReturn: string },
|
||||||
|
affiliationChain?: string[],
|
||||||
) {
|
) {
|
||||||
const affiliationAgents = this.buildAffiliationAgents(u.parent);
|
const affiliationAgents = this.buildAffiliationAgents(u.parent, affiliationChain);
|
||||||
return {
|
return {
|
||||||
id: u.id.toString(),
|
id: u.id.toString(),
|
||||||
username: u.username,
|
username: u.username,
|
||||||
@@ -242,8 +290,15 @@ export class UsersService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
||||||
|
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
|
||||||
return {
|
return {
|
||||||
items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))),
|
items: rows.map((u) =>
|
||||||
|
this.formatPlayerRow(
|
||||||
|
u,
|
||||||
|
betMap.get(u.id.toString()),
|
||||||
|
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -269,6 +324,7 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
|
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
|
||||||
|
|
||||||
|
const affiliationMap = await this.buildAffiliationChainMap([user.parentId]);
|
||||||
const [betCount, betStake] = await Promise.all([
|
const [betCount, betStake] = await Promise.all([
|
||||||
this.prisma.bet.count({ where: { userId: playerId } }),
|
this.prisma.bet.count({ where: { userId: playerId } }),
|
||||||
this.prisma.bet.aggregate({
|
this.prisma.bet.aggregate({
|
||||||
@@ -278,7 +334,11 @@ export class UsersService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.formatPlayerRow(user),
|
...this.formatPlayerRow(
|
||||||
|
user,
|
||||||
|
undefined,
|
||||||
|
user.parentId ? affiliationMap.get(user.parentId.toString()) : undefined,
|
||||||
|
),
|
||||||
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
||||||
loginFailCount: user.auth?.loginFailCount ?? 0,
|
loginFailCount: user.auth?.loginFailCount ?? 0,
|
||||||
lockedUntil: user.auth?.lockedUntil ?? null,
|
lockedUntil: user.auth?.lockedUntil ?? null,
|
||||||
|
|||||||
@@ -309,6 +309,227 @@ export class WalletService {
|
|||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
|
||||||
|
const cat = category?.trim();
|
||||||
|
if (cat === 'deposit') {
|
||||||
|
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
|
||||||
|
}
|
||||||
|
if (cat === 'withdraw') {
|
||||||
|
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
|
||||||
|
}
|
||||||
|
if (cat === 'bet') {
|
||||||
|
return {
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cat === 'cashback') {
|
||||||
|
return { transactionType: { in: ['CASHBACK', 'CASHBACK_DEPOSIT'] } };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWalletTransactionsAdmin(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
playerId?: bigint;
|
||||||
|
parentAgentId?: bigint;
|
||||||
|
parentAgentKeyword?: string;
|
||||||
|
scopedParentAgentIds?: bigint[];
|
||||||
|
keyword?: string;
|
||||||
|
operatorKeyword?: string;
|
||||||
|
transactionType?: string;
|
||||||
|
typeCategory?: string;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
}) {
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.WalletTransactionWhereInput = {};
|
||||||
|
|
||||||
|
const explicitType = params.transactionType?.trim();
|
||||||
|
if (explicitType) {
|
||||||
|
where.transactionType = explicitType;
|
||||||
|
} else if (params.typeCategory?.trim()) {
|
||||||
|
Object.assign(where, this.walletTypeCategoryWhere(params.typeCategory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.dateFrom || params.dateTo) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
|
||||||
|
if (params.dateTo) where.createdAt.lte = params.dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatorKeyword = params.operatorKeyword?.trim();
|
||||||
|
if (operatorKeyword) {
|
||||||
|
const matchedOps = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
username: { contains: operatorKeyword, mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
const operatorIds = matchedOps.map((u) => u.id);
|
||||||
|
if (!operatorIds.length) {
|
||||||
|
return { items: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
where.operatorId = { in: operatorIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
let playerIds: bigint[] | undefined;
|
||||||
|
|
||||||
|
if (params.playerId) {
|
||||||
|
playerIds = [params.playerId];
|
||||||
|
} else {
|
||||||
|
const playerWhere: Prisma.UserWhereInput = {
|
||||||
|
userType: 'PLAYER',
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.parentAgentId) {
|
||||||
|
playerWhere.parentId = params.parentAgentId;
|
||||||
|
} else if (params.parentAgentKeyword?.trim()) {
|
||||||
|
const matchedAgents = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
userType: 'AGENT',
|
||||||
|
deletedAt: null,
|
||||||
|
username: { contains: params.parentAgentKeyword.trim(), mode: 'insensitive' },
|
||||||
|
...(params.scopedParentAgentIds?.length
|
||||||
|
? { id: { in: params.scopedParentAgentIds } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
const agentIds = matchedAgents.map((a) => a.id);
|
||||||
|
if (!agentIds.length) {
|
||||||
|
return { items: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
playerWhere.parentId = { in: agentIds };
|
||||||
|
} else if (params.scopedParentAgentIds?.length) {
|
||||||
|
playerWhere.parentId = { in: params.scopedParentAgentIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = params.keyword?.trim();
|
||||||
|
if (keyword) {
|
||||||
|
playerWhere.username = { contains: keyword, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.parentAgentId ||
|
||||||
|
params.parentAgentKeyword?.trim() ||
|
||||||
|
params.scopedParentAgentIds?.length ||
|
||||||
|
keyword
|
||||||
|
) {
|
||||||
|
const players = await this.prisma.user.findMany({
|
||||||
|
where: playerWhere,
|
||||||
|
select: { id: true },
|
||||||
|
take: 500,
|
||||||
|
});
|
||||||
|
playerIds = players.map((p) => p.id);
|
||||||
|
if (!playerIds.length) {
|
||||||
|
return { items: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerIds) {
|
||||||
|
where.userId = { in: playerIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.walletTransaction.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.walletTransaction.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userIds = [...new Set(rows.map((r) => r.userId))];
|
||||||
|
const operatorIds = [
|
||||||
|
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [players, operators] = await Promise.all([
|
||||||
|
userIds.length
|
||||||
|
? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true, parentId: true },
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
operatorIds.length
|
||||||
|
? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: operatorIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parentIds = [
|
||||||
|
...new Set(players.map((p) => p.parentId).filter((id): id is bigint => id != null)),
|
||||||
|
];
|
||||||
|
const parentAgents = parentIds.length
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: parentIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const playerById = new Map(players.map((p) => [p.id.toString(), p]));
|
||||||
|
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||||||
|
const parentById = new Map(parentAgents.map((a) => [a.id.toString(), a.username]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => {
|
||||||
|
const player = playerById.get(row.userId.toString());
|
||||||
|
const parentId = player?.parentId;
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
transactionId: row.transactionId,
|
||||||
|
playerId: row.userId.toString(),
|
||||||
|
playerUsername: player?.username ?? null,
|
||||||
|
parentAgentId: parentId?.toString() ?? null,
|
||||||
|
parentAgentUsername: parentId ? (parentById.get(parentId.toString()) ?? null) : null,
|
||||||
|
transactionType: row.transactionType,
|
||||||
|
amount: row.amount.toString(),
|
||||||
|
balanceBefore: row.balanceBefore.toString(),
|
||||||
|
balanceAfter: row.balanceAfter.toString(),
|
||||||
|
frozenBefore: row.frozenBefore.toString(),
|
||||||
|
frozenAfter: row.frozenAfter.toString(),
|
||||||
|
referenceType: row.referenceType,
|
||||||
|
referenceId: row.referenceId,
|
||||||
|
betNo: row.referenceType === 'BET' ? row.referenceId : null,
|
||||||
|
operatorId: row.operatorId?.toString() ?? null,
|
||||||
|
operatorUsername: row.operatorId
|
||||||
|
? (operatorById.get(row.operatorId.toString()) ?? null)
|
||||||
|
: null,
|
||||||
|
remark: row.remark,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listTransferTransactions(params: {
|
async listTransferTransactions(params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
|
|||||||
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_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_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_players';
|
||||||
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
|
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
|
||||||
|
export const AGENT_MAX_LEVEL = 'agent.max_level';
|
||||||
|
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
|
||||||
|
|
||||||
|
export type AgentHierarchySettings = {
|
||||||
|
/** 最大代理层级;0 = 不限制 */
|
||||||
|
maxAgentLevel: number;
|
||||||
|
/** 创建下级代理时默认授信占上级可用授信的比例(1–100) */
|
||||||
|
defaultSubAgentCreditRatio: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlayerAccountSettings = {
|
export type PlayerAccountSettings = {
|
||||||
allowPasswordChange: boolean;
|
allowPasswordChange: boolean;
|
||||||
@@ -40,6 +49,25 @@ export class SystemConfigService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getInt(key: string, defaultValue: number): Promise<number> {
|
||||||
|
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||||
|
if (!row) return defaultValue;
|
||||||
|
const parsed = parseInt(row.configValue, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setInt(key: string, value: number, description?: string) {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { configKey: key },
|
||||||
|
create: {
|
||||||
|
configKey: key,
|
||||||
|
configValue: String(value),
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
update: { configValue: String(value) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getPlayerAccountSettings(): Promise<PlayerAccountSettings> {
|
async getPlayerAccountSettings(): Promise<PlayerAccountSettings> {
|
||||||
const [allowPasswordChange, allowUsernameChange] = await Promise.all([
|
const [allowPasswordChange, allowUsernameChange] = await Promise.all([
|
||||||
this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true),
|
this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true),
|
||||||
@@ -91,4 +119,40 @@ export class SystemConfigService {
|
|||||||
}
|
}
|
||||||
return this.getAgentSuspendSettings();
|
return this.getAgentSuspendSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAgentHierarchySettings(): Promise<AgentHierarchySettings> {
|
||||||
|
const [maxAgentLevel, defaultSubAgentCreditRatio] = await Promise.all([
|
||||||
|
this.getInt(AGENT_MAX_LEVEL, 0),
|
||||||
|
this.getInt(AGENT_DEFAULT_SUB_CREDIT_RATIO, 50),
|
||||||
|
]);
|
||||||
|
return { maxAgentLevel, defaultSubAgentCreditRatio };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAgentHierarchySettings(data: Partial<AgentHierarchySettings>) {
|
||||||
|
if (data.maxAgentLevel !== undefined) {
|
||||||
|
if (!Number.isInteger(data.maxAgentLevel) || data.maxAgentLevel < 0) {
|
||||||
|
throw new Error('maxAgentLevel must be a non-negative integer');
|
||||||
|
}
|
||||||
|
await this.setInt(
|
||||||
|
AGENT_MAX_LEVEL,
|
||||||
|
data.maxAgentLevel,
|
||||||
|
'最大代理层级;0 表示不限制',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.defaultSubAgentCreditRatio !== undefined) {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(data.defaultSubAgentCreditRatio) ||
|
||||||
|
data.defaultSubAgentCreditRatio < 1 ||
|
||||||
|
data.defaultSubAgentCreditRatio > 100
|
||||||
|
) {
|
||||||
|
throw new Error('defaultSubAgentCreditRatio must be an integer between 1 and 100');
|
||||||
|
}
|
||||||
|
await this.setInt(
|
||||||
|
AGENT_DEFAULT_SUB_CREDIT_RATIO,
|
||||||
|
data.defaultSubAgentCreditRatio,
|
||||||
|
'创建下级代理时默认授信占上级可用授信的比例(%)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getAgentHierarchySettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import MatchBetCard from './MatchBetCard.vue';
|
import MatchBetCard from './MatchBetCard.vue';
|
||||||
import saishiImg from '../assets/images/saishi.png';
|
import saishiImg from '../assets/images/saishi.png';
|
||||||
|
import cardBg from '../assets/images/card-bg.png';
|
||||||
|
|
||||||
defineProps<{
|
const matchCardBg = `url(${cardBg})`;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
leagueLogoUrl?: string | null;
|
leagueLogoUrl?: string | null;
|
||||||
@@ -28,6 +32,10 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
||||||
|
|
||||||
|
const totalCount = computed(() => props.matches.length);
|
||||||
|
const liveCount = computed(() => props.matches.filter(m => m.matchPhase === 'closed_pending').length);
|
||||||
|
const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'open').length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,7 +44,14 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
|||||||
<span class="toggle-icon" :class="{ open: expanded }" aria-hidden="true">
|
<span class="toggle-icon" :class="{ open: expanded }" aria-hidden="true">
|
||||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="league-info">
|
||||||
<span class="league-title">*{{ leagueName }}</span>
|
<span class="league-title">*{{ leagueName }}</span>
|
||||||
|
<span class="league-stats">
|
||||||
|
<span class="stat-item">{{ totalCount }}场</span>
|
||||||
|
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }}进行中</span>
|
||||||
|
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }}待开赛</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<img
|
<img
|
||||||
:src="leagueLogoUrl || saishiImg"
|
:src="leagueLogoUrl || saishiImg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -59,19 +74,23 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.league-block {
|
.league-block {
|
||||||
margin-bottom: 10px;
|
position: relative;
|
||||||
border: 1px solid #2e2e2e;
|
isolation: isolate;
|
||||||
border-radius: 6px;
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #141414;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.league-row {
|
.league-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 12px;
|
padding: 12px 16px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -79,6 +98,16 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.league-row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||||
|
opacity: 0.25;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.league-row:active {
|
.league-row:active {
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
}
|
}
|
||||||
@@ -103,15 +132,39 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
|||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.league-title {
|
.league-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--primary-light);
|
color: #fff;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.league-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-live {
|
||||||
|
color: #3db865;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-open {
|
||||||
|
color: #c8a84e;
|
||||||
|
}
|
||||||
|
|
||||||
.league-saishi {
|
.league-saishi {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import { teamFlagUrl } from '../utils/teamFlag';
|
||||||
import StatusWatermark from './StatusWatermark.vue';
|
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||||
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../utils/matchPhase';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
match: {
|
match: {
|
||||||
@@ -76,12 +75,9 @@ const liveScoreText = computed(() => {
|
|||||||
:class="{ 'match-card--phase': phase !== 'open' }"
|
:class="{ 'match-card--phase': phase !== 'open' }"
|
||||||
@click="emit('bet', match.id)"
|
@click="emit('bet', match.id)"
|
||||||
>
|
>
|
||||||
<StatusWatermark
|
<span v-if="phase === 'open'" class="status-tag status-tag--open">待开赛</span>
|
||||||
v-if="phase !== 'open'"
|
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
||||||
:label="phaseLabel"
|
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
||||||
:variant="matchPhaseVariant(phase)"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="teams-row">
|
<div class="teams-row">
|
||||||
<div class="team">
|
<div class="team">
|
||||||
@@ -127,8 +123,8 @@ const liveScoreText = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.match-card {
|
.match-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0d0d0d;
|
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||||
border: 1px solid rgba(255, 215, 0, 0.25);
|
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px 8px 10px;
|
padding: 10px 8px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -140,8 +136,17 @@ const liveScoreText = computed(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.match-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.match-card--phase {
|
.match-card--phase {
|
||||||
border-color: rgba(140, 140, 140, 0.35);
|
/* inherits base dark style */
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-row {
|
.teams-row {
|
||||||
@@ -170,7 +175,7 @@ const liveScoreText = computed(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--primary-light);
|
color: #fff;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -210,7 +215,7 @@ const liveScoreText = computed(() => {
|
|||||||
.kickoff {
|
.kickoff {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #b0a060;
|
color: #fff;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||||
@@ -233,6 +238,40 @@ const liveScoreText = computed(() => {
|
|||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 0 6px 0 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--open {
|
||||||
|
background: linear-gradient(135deg, #e8c84a, #d4a017);
|
||||||
|
color: #1a1a1a;
|
||||||
|
box-shadow: 0 2px 6px rgba(212, 160, 23, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--settled {
|
||||||
|
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
|
||||||
|
color: #8a9ab8;
|
||||||
|
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
|
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--pending {
|
||||||
|
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
||||||
|
color: #c8a84e;
|
||||||
|
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
||||||
|
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.bet-btn {
|
.bet-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ withDefaults(
|
|||||||
max-width: 92%;
|
max-width: 92%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
z-index: 1;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.sm {
|
.status-watermark.sm {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { computed } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import OutrightOptionCard from './OutrightOptionCard.vue';
|
import OutrightOptionCard from './OutrightOptionCard.vue';
|
||||||
import saishiImg from '../../assets/images/saishi.png';
|
import saishiImg from '../../assets/images/saishi.png';
|
||||||
|
import cardBg from '../../assets/images/card-bg.png';
|
||||||
|
|
||||||
|
const matchCardBg = `url(${cardBg})`;
|
||||||
|
|
||||||
export interface OutrightSelection {
|
export interface OutrightSelection {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,22 +83,38 @@ const headMeta = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.event-block {
|
.event-block {
|
||||||
margin-bottom: 10px;
|
position: relative;
|
||||||
border: 1px solid #2e2e2e;
|
isolation: isolate;
|
||||||
border-radius: 6px;
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-head {
|
.event-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 10px;
|
padding: 12px 16px;
|
||||||
background: #141414;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-head::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||||
|
opacity: 0.25;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-icon {
|
.toggle-icon {
|
||||||
|
|||||||
@@ -72,21 +72,30 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.option-card {
|
.option-card {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 4px 6px;
|
padding: 8px 4px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #1c1c1c;
|
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||||
border: 1px solid #333;
|
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-card:active {
|
.option-card:active {
|
||||||
background: #252525;
|
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from
|
|||||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||||
import GoldSpinner from '../GoldSpinner.vue';
|
import GoldSpinner from '../GoldSpinner.vue';
|
||||||
import TeamEmblem from '../TeamEmblem.vue';
|
import TeamEmblem from '../TeamEmblem.vue';
|
||||||
import StatusWatermark from '../StatusWatermark.vue';
|
import cardBg from '../../assets/images/card-bg.png';
|
||||||
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../../utils/matchPhase';
|
import { matchPhaseLabel, type MatchPhase } from '../../utils/matchPhase';
|
||||||
|
|
||||||
|
const matchCardBg = `url(${cardBg})`;
|
||||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||||
|
|
||||||
type TimeFilter = 'all' | 'today';
|
type TimeFilter = 'all' | 'today';
|
||||||
@@ -301,18 +303,22 @@ function toggleCollapse(id: string) {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
||||||
<StatusWatermark
|
<span
|
||||||
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) !== 'open'"
|
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'settled'"
|
||||||
:label="matchPhaseLabel(t, match.matchPhase ?? 'closed_pending')"
|
class="status-tag status-tag--settled"
|
||||||
:variant="matchPhaseVariant(match.matchPhase ?? 'closed_pending')"
|
>{{ matchPhaseLabel(t, 'settled') }}</span>
|
||||||
size="sm"
|
<span
|
||||||
/>
|
v-else-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'closed_pending'"
|
||||||
|
class="status-tag status-tag--pending"
|
||||||
|
>{{ matchPhaseLabel(t, 'closed_pending') }}</span>
|
||||||
|
|
||||||
<div class="match-head-top">
|
<div class="match-head-top">
|
||||||
<span class="m-league">{{ match.leagueName }}</span>
|
<span class="toggle-icon" :class="{ open: !collapsed.has(match.id) }" aria-hidden="true">
|
||||||
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">
|
<span class="toggle-mark">{{ collapsed.has(match.id) ? '+' : '−' }}</span>
|
||||||
{{ collapsed.has(match.id) ? '+' : '−' }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="m-league">{{ match.leagueName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="match-head-teams">
|
<div class="match-head-teams">
|
||||||
<div class="m-team home">
|
<div class="m-team home">
|
||||||
<TeamEmblem
|
<TeamEmblem
|
||||||
@@ -331,7 +337,6 @@ function toggleCollapse(id: string) {
|
|||||||
{{ match.score.ftHome }} - {{ match.score.ftAway }}
|
{{ match.score.ftHome }} - {{ match.score.ftAway }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
<span v-else class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
||||||
<span class="m-vs">VS</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="m-team away">
|
<div class="m-team away">
|
||||||
<span class="m-name">{{ match.awayTeamName }}</span>
|
<span class="m-name">{{ match.awayTeamName }}</span>
|
||||||
@@ -491,25 +496,36 @@ function toggleCollapse(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.match-card {
|
.match-card {
|
||||||
background: #141414;
|
position: relative;
|
||||||
border: 1px solid #2a2a2a;
|
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||||
|
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.match-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.match-card.collapsed {
|
.match-card.collapsed {
|
||||||
border-color: #222;
|
border-color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-head {
|
.match-head {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px 12px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -518,6 +534,16 @@ function toggleCollapse(id: string) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.match-head::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||||
|
opacity: 0.25;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.match-head:active {
|
.match-head:active {
|
||||||
background: rgba(255, 255, 255, 0.015);
|
background: rgba(255, 255, 255, 0.015);
|
||||||
}
|
}
|
||||||
@@ -525,22 +551,22 @@ function toggleCollapse(id: string) {
|
|||||||
.match-head-top {
|
.match-head-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-head-teams {
|
.toggle-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-team {
|
.m-team {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -553,15 +579,15 @@ function toggleCollapse(id: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 4px;
|
min-width: 50px;
|
||||||
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-name {
|
.m-name {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--primary-light);
|
color: #fff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -575,40 +601,45 @@ function toggleCollapse(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-time {
|
.m-time {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-dot {
|
.toggle-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 20px;
|
width: 26px;
|
||||||
height: 20px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #1a1a1a;
|
background: #141414;
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-gold-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-dot.open {
|
.toggle-icon.open {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-mark {
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-league {
|
.m-league {
|
||||||
font-size: 10px;
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-head :deep(.team-emblem) {
|
.match-head :deep(.team-emblem) {
|
||||||
@@ -618,11 +649,13 @@ function toggleCollapse(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.market-blocks {
|
.market-blocks {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px 10px;
|
padding: 8px 10px 10px;
|
||||||
border-top: 1px solid #1e1e1e;
|
border-top: 1px solid rgba(140, 140, 140, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-group {
|
.market-group {
|
||||||
@@ -699,10 +732,39 @@ function toggleCollapse(id: string) {
|
|||||||
opacity: 0.94;
|
opacity: 0.94;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-score {
|
.status-tag {
|
||||||
font-size: 12px;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 5;
|
||||||
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 0 6px 0 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--settled {
|
||||||
|
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
|
||||||
|
color: #8a9ab8;
|
||||||
|
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
|
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--pending {
|
||||||
|
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
||||||
|
color: #c8a84e;
|
||||||
|
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
||||||
|
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-score {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odd-label {
|
.odd-label {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const i18n = createI18n({
|
|||||||
back: '返回',
|
back: '返回',
|
||||||
not_found: '注单不存在',
|
not_found: '注单不存在',
|
||||||
my_pick: '我的选择',
|
my_pick: '我的选择',
|
||||||
|
my_bets: '我的投注',
|
||||||
legs: '串关明细',
|
legs: '串关明细',
|
||||||
summary: '投注摘要',
|
summary: '投注摘要',
|
||||||
bet_no: '注单号',
|
bet_no: '注单号',
|
||||||
@@ -358,6 +359,7 @@ const i18n = createI18n({
|
|||||||
back: 'Back',
|
back: 'Back',
|
||||||
not_found: 'Bet not found',
|
not_found: 'Bet not found',
|
||||||
my_pick: 'My pick',
|
my_pick: 'My pick',
|
||||||
|
my_bets: 'My bets',
|
||||||
legs: 'Parlay legs',
|
legs: 'Parlay legs',
|
||||||
summary: 'Summary',
|
summary: 'Summary',
|
||||||
bet_no: 'Bet ID',
|
bet_no: 'Bet ID',
|
||||||
@@ -673,6 +675,7 @@ const i18n = createI18n({
|
|||||||
back: 'Kembali',
|
back: 'Kembali',
|
||||||
not_found: 'Pertaruhan tidak dijumpai',
|
not_found: 'Pertaruhan tidak dijumpai',
|
||||||
my_pick: 'Pilihan saya',
|
my_pick: 'Pilihan saya',
|
||||||
|
my_bets: 'Pertaruhan saya',
|
||||||
legs: 'Butiran berganda',
|
legs: 'Butiran berganda',
|
||||||
summary: 'Ringkasan',
|
summary: 'Ringkasan',
|
||||||
bet_no: 'ID Pertaruhan',
|
bet_no: 'ID Pertaruhan',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { formatMoney } from '../utils/localeDisplay';
|
||||||
import { useBetSlipStore } from '../stores/betSlip';
|
import { useBetSlipStore } from '../stores/betSlip';
|
||||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||||
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
||||||
@@ -82,6 +83,53 @@ const csMessage = ref('');
|
|||||||
const showCsSuccess = ref(false);
|
const showCsSuccess = ref(false);
|
||||||
const csConfirmOpen = ref(false);
|
const csConfirmOpen = ref(false);
|
||||||
const csConfirmMarketType = ref<string | null>(null);
|
const csConfirmMarketType = ref<string | null>(null);
|
||||||
|
|
||||||
|
interface MyBet {
|
||||||
|
betNo: string;
|
||||||
|
betType: string;
|
||||||
|
stake: string;
|
||||||
|
totalOdds: string;
|
||||||
|
potentialReturn: string;
|
||||||
|
actualReturn: string;
|
||||||
|
status: string;
|
||||||
|
placedAt: string;
|
||||||
|
pickLabel: string;
|
||||||
|
matchTitle: string;
|
||||||
|
}
|
||||||
|
const myBets = ref<MyBet[]>([]);
|
||||||
|
const loadingMyBets = ref(false);
|
||||||
|
|
||||||
|
async function loadMyBets() {
|
||||||
|
if (!match.value) return;
|
||||||
|
loadingMyBets.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/player/bets?page=1');
|
||||||
|
const items = (data.data?.items ?? data.data ?? []) as MyBet[];
|
||||||
|
const matchTitle = `${match.value.homeTeamName} vs ${match.value.awayTeamName}`;
|
||||||
|
myBets.value = items.filter(
|
||||||
|
(b) => b.matchTitle === matchTitle || b.matchTitle === `${match.value!.awayTeamName} vs ${match.value!.homeTeamName}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
myBets.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingMyBets.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: string) => {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
if (s === 'WON' || s === 'WIN') return t('history.status_won');
|
||||||
|
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
|
||||||
|
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
|
||||||
|
return t('history.status_pending');
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = (status: string) => {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
if (s === 'WON' || s === 'WIN') return 'bet-status-won';
|
||||||
|
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
|
||||||
|
return 'bet-status-pending';
|
||||||
|
};
|
||||||
const marketsByType = computed(() => {
|
const marketsByType = computed(() => {
|
||||||
const map = new Map<string, Market>();
|
const map = new Map<string, Market>();
|
||||||
for (const m of match.value?.markets ?? []) {
|
for (const m of match.value?.markets ?? []) {
|
||||||
@@ -237,6 +285,7 @@ async function loadMatch() {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
loadMyBets();
|
||||||
}
|
}
|
||||||
|
|
||||||
useOnLocaleChange(loadMatch);
|
useOnLocaleChange(loadMatch);
|
||||||
@@ -384,6 +433,25 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
|
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 我的投注 -->
|
||||||
|
<section v-if="myBets.length" class="my-bets-section">
|
||||||
|
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
|
||||||
|
<div class="my-bets-list">
|
||||||
|
<div v-for="bet in myBets" :key="bet.betNo" class="my-bet-card" @click="router.push(`/bets/${bet.betNo}`)">
|
||||||
|
<div class="bet-header">
|
||||||
|
<span class="bet-type">{{ bet.betType === 'PARLAY' ? t('history.parlay_league') : bet.pickLabel }}</span>
|
||||||
|
<span class="bet-status" :class="statusClass(bet.status)">{{ statusLabel(bet.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bet-footer">
|
||||||
|
<span class="bet-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
|
||||||
|
<span class="bet-return" :class="statusClass(bet.status)">
|
||||||
|
{{ statusClass(bet.status) === 'bet-status-won' ? '+' : '' }}{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="markets-section">
|
<section class="markets-section">
|
||||||
<CorrectScoreConfirmModal
|
<CorrectScoreConfirmModal
|
||||||
:open="csConfirmOpen"
|
:open="csConfirmOpen"
|
||||||
@@ -777,4 +845,93 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
padding: 2px 0 6px;
|
padding: 2px 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 我的投注 ── */
|
||||||
|
.my-bets-section {
|
||||||
|
padding: 0 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-bets-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ccc;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-bets-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-bet-card {
|
||||||
|
background: #1c1c1c;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-bet-card:active {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-status-won {
|
||||||
|
background: rgba(61, 184, 101, 0.15);
|
||||||
|
color: #3db865;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-status-lost {
|
||||||
|
background: rgba(224, 80, 80, 0.15);
|
||||||
|
color: #e05050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-status-pending {
|
||||||
|
background: rgba(232, 200, 74, 0.15);
|
||||||
|
color: #e8c84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-stake {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-return {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -390,9 +390,24 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'ms-MY': 'Pengguna sudah menjadi ejen',
|
'ms-MY': 'Pengguna sudah menjadi ejen',
|
||||||
},
|
},
|
||||||
AGENT_LEVEL_INVALID: {
|
AGENT_LEVEL_INVALID: {
|
||||||
'zh-CN': '代理级别须为 1 或 2',
|
'zh-CN': '代理级别无效',
|
||||||
'en-US': 'Agent level must be 1 or 2',
|
'en-US': 'Invalid agent level',
|
||||||
'ms-MY': 'Tahap ejen mesti 1 atau 2',
|
'ms-MY': 'Tahap ejen tidak sah',
|
||||||
|
},
|
||||||
|
AGENT_MAX_LEVEL_REACHED: {
|
||||||
|
'zh-CN': '已达到最大代理层级,无法继续创建下级',
|
||||||
|
'en-US': 'Maximum agent level reached; cannot create sub-agents',
|
||||||
|
'ms-MY': 'Tahap ejen maksimum dicapai; tidak boleh cipta sub-ejen',
|
||||||
|
},
|
||||||
|
AGENT_PARENT_LEVEL_MISMATCH: {
|
||||||
|
'zh-CN': '上级代理层级与目标层级不匹配',
|
||||||
|
'en-US': 'Parent agent level does not match target level',
|
||||||
|
'ms-MY': 'Tahap ejen induk tidak sepadan dengan tahap sasaran',
|
||||||
|
},
|
||||||
|
AGENT_LEVEL_ROOT_INVALID: {
|
||||||
|
'zh-CN': '一级代理不可指定上级',
|
||||||
|
'en-US': 'Root-level agents cannot have a parent',
|
||||||
|
'ms-MY': 'Ejen peringkat akar tidak boleh ada induk',
|
||||||
},
|
},
|
||||||
LEVEL2_REQUIRES_PARENT: {
|
LEVEL2_REQUIRES_PARENT: {
|
||||||
'zh-CN': '二级代理必须指定上级代理',
|
'zh-CN': '二级代理必须指定上级代理',
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export enum UserStatus {
|
|||||||
LOCKED = 'LOCKED',
|
LOCKED = 'LOCKED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimum agent tier (root agents). Actual levels are unbounded integers when `agent.max_level` is 0. */
|
||||||
|
export const MIN_AGENT_LEVEL = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy enum for the original two-tier demo. Production code should use numeric `agentLevel` / `AgentProfile.level`.
|
||||||
|
*/
|
||||||
export enum AgentLevel {
|
export enum AgentLevel {
|
||||||
LEVEL_1 = 1,
|
LEVEL_1 = 1,
|
||||||
LEVEL_2 = 2,
|
LEVEL_2 = 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user