feat: 开户备注、账单展示优化与后台代理管理增强

- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示

- 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分

- 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端

- 后台代理/玩家表格操作栏重构,充值订单增加备注列

- 前台个人中心、充值、账单与验证码组件体验优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:23:58 +08:00
parent 10485ecfaf
commit 03e72ca9b2
46 changed files with 3721 additions and 1059 deletions

View File

@@ -360,6 +360,15 @@ body::-webkit-scrollbar {
.admin-list-page .table-wrap .el-table th.el-table__cell .cell {
white-space: nowrap;
}
.admin-list-page .table-wrap.is-compact-row-actions .admin-responsive-actions__inline {
display: none !important;
}
.admin-list-page .table-wrap.is-compact-row-actions .admin-responsive-actions__menu {
display: inline-flex !important;
}
.admin-list-page .table-wrap:not(.is-compact-row-actions) .admin-responsive-actions__menu {
display: none !important;
}
.admin-list-page .list-hint {
flex-shrink: 0;
margin: 0;
@@ -737,6 +746,122 @@ body {
overflow-y: auto;
}
.user-edit-dialog .edit-meta,
.agent-edit-dialog .edit-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 12px;
color: #888;
}
.user-edit-dialog .edit-form-section,
.agent-edit-dialog .edit-form-section {
margin-bottom: 12px;
}
.user-edit-dialog .edit-form-section:last-child,
.agent-edit-dialog .edit-form-section:last-child {
margin-bottom: 0;
}
.user-edit-dialog .section-title,
.agent-edit-dialog .section-title {
font-size: 11px;
font-weight: 700;
color: #888;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.user-edit-dialog .compact-edit-form .el-form-item,
.agent-edit-dialog .compact-edit-form .el-form-item {
margin-bottom: 10px;
}
.user-edit-dialog .field-hint,
.agent-edit-dialog .field-hint {
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.4;
}
.user-edit-dialog .inline-hint,
.agent-edit-dialog .inline-hint {
margin-top: 0;
}
.user-edit-dialog .password-mgmt-block,
.agent-edit-dialog .password-mgmt-block {
margin: 0;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.user-edit-dialog .block-title,
.agent-edit-dialog .block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.user-edit-dialog .password-field-row,
.agent-edit-dialog .password-field-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px 10px;
align-items: center;
margin-bottom: 8px;
}
.user-edit-dialog .password-field-row:last-child,
.agent-edit-dialog .password-field-row:last-child {
margin-bottom: 0;
}
.user-edit-dialog .password-field-label,
.agent-edit-dialog .password-field-label {
font-size: 12px;
color: #888;
line-height: 1.35;
}
.user-edit-dialog .password-plain,
.agent-edit-dialog .password-plain {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
letter-spacing: 0.06em;
}
.user-edit-dialog .password-empty,
.agent-edit-dialog .password-empty {
color: #666;
}
.user-edit-dialog .block-hint,
.agent-edit-dialog .block-hint {
margin: -2px 0 8px;
padding-left: 82px;
}
.user-edit-dialog .cashback-edit-block,
.agent-edit-dialog .cashback-edit-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.user-edit-dialog .contact-row,
.agent-edit-dialog .contact-row {
margin-bottom: 0;
}
.user-edit-dialog .contact-row .el-form-item,
.agent-edit-dialog .contact-row .el-form-item {
margin-bottom: 0;
}
.user-edit-dialog .edit-stats-panel,
.agent-edit-dialog .edit-stats-panel {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.agent-edit-dialog .edit-stats {
margin-top: 4px;
}
.entity-detail-dialog .el-dialog__body {
padding: 12px 20px 16px !important;
max-height: none !important;

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useAdminLocale } from '../composables/useAdminLocale';
import AdminResponsiveRowActions from './AdminResponsiveRowActions.vue';
export type AgentActionRow = {
userId: number | string;
status: string;
};
withDefaults(
defineProps<{
row: AgentActionRow;
showDetail?: boolean;
canCreateSub?: boolean;
createSubLabel?: string;
}>(),
{
showDetail: true,
canCreateSub: false,
createSubLabel: '',
},
);
const emit = defineEmits<{
detail: [];
edit: [];
credit: [];
createSub: [];
freeze: [];
}>();
const { t } = useAdminLocale();
</script>
<template>
<AdminResponsiveRowActions>
<template #inline>
<el-button v-if="showDetail" link type="primary" size="small" @click="emit('detail')">
{{ t('common.detail') }}
</el-button>
<el-button link type="primary" size="small" @click="emit('edit')">{{ t('common.edit') }}</el-button>
<el-button link type="primary" size="small" @click="emit('credit')">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canCreateSub" link type="primary" size="small" @click="emit('createSub')">
{{ createSubLabel }}
</el-button>
<el-button
v-if="row.status === 'ACTIVE'"
link
type="warning"
size="small"
@click="emit('freeze')"
>
{{ t('common.freeze') }}
</el-button>
<el-button v-else link type="primary" size="small" @click="emit('freeze')">
{{ t('common.unfreeze') }}
</el-button>
</template>
<template #menu>
<el-dropdown-item v-if="showDetail" @click="emit('detail')">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="emit('edit')">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="emit('credit')">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canCreateSub" @click="emit('createSub')">{{ createSubLabel }}</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="emit('freeze')">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="emit('freeze')">{{ t('common.unfreeze') }}</el-dropdown-item>
</template>
</AdminResponsiveRowActions>
</template>
<style scoped>
:deep(.action-warning) {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
defineProps<{
name: string;
}>();
</script>
<template>
<svg
class="admin-nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'dashboard'">
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</template>
<template v-else-if="name === 'matches'">
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M7 4h10v5a5 5 0 01-10 0V4z" />
<path d="M5 4H3v2a3 3 0 003 3" />
<path d="M19 4h2v2a3 3 0 01-3 3" />
</template>
<template v-else-if="name === 'users'">
<circle cx="9" cy="8" r="3" />
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
<circle cx="17" cy="9" r="2" />
<path d="M14 20c0-2.2 1.8-4 4-4" />
</template>
<template v-else-if="name === 'finance'">
<path d="M4 6h16M4 10h16M4 14h10" />
<path d="M18 14l2 2 4-4" />
</template>
<template v-else-if="name === 'deposit'">
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18" />
<path d="M7 15h4" />
</template>
<template v-else-if="name === 'cashback'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4l2.5 2.5" />
</template>
<template v-else-if="name === 'bets'">
<path d="M8 4h8l2 4v12a2 2 0 01-2 2H8a2 2 0 01-2-2V4z" />
<path d="M8 8h8M8 12h6M8 16h4" />
</template>
<template v-else-if="name === 'contents'">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</template>
<template v-else-if="name === 'media'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="11" r="2" />
<path d="M21 15l-4.5-4.5L7 19" />
</template>
<template v-else-if="name === 'audit'">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
<path d="M9 12h6M9 16h4" />
</template>
<template v-else-if="name === 'smoke-tests'">
<path d="M9 3h6l1 7H8L9 3z" />
<path d="M10 10v8M14 10v8" />
<path d="M8 18h8" />
<circle cx="12" cy="7" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else>
<circle cx="12" cy="12" r="8" />
</template>
</svg>
</template>
<style scoped>
.admin-nav-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>

View File

@@ -1,10 +1,71 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
import { useAdminTableRowActionsMenu } from '../composables/useAdminTableRowActionsLayout';
const rootRef = ref<HTMLElement | null>(null);
const inlineRef = ref<HTMLElement | null>(null);
const localUseMenu = ref(false);
const sharedUseMenu = useAdminTableRowActionsMenu();
const useMenu = computed(() => sharedUseMenu?.value ?? localUseMenu.value);
const ACTION_GAP = 6;
const MOBILE_MAX = 768;
function measureInlineWidth(inline: HTMLElement) {
const buttons = Array.from(inline.querySelectorAll<HTMLElement>('.el-button'));
if (buttons.length === 0) return 0;
return buttons.reduce((sum, btn, idx) => sum + btn.offsetWidth + (idx > 0 ? ACTION_GAP : 0), 0);
}
function updateLocalMode() {
if (sharedUseMenu != null) return;
const root = rootRef.value;
const inline = inlineRef.value;
if (!root || !inline) return;
if (window.innerWidth <= MOBILE_MAX) {
localUseMenu.value = true;
return;
}
const total = measureInlineWidth(inline);
localUseMenu.value = total > root.clientWidth;
}
let rootObserver: ResizeObserver | null = null;
onMounted(async () => {
if (sharedUseMenu != null) return;
await nextTick();
updateLocalMode();
if (rootRef.value) {
rootObserver = new ResizeObserver(() => updateLocalMode());
rootObserver.observe(rootRef.value);
}
window.addEventListener('resize', updateLocalMode);
});
onBeforeUnmount(() => {
rootObserver?.disconnect();
if (sharedUseMenu == null) {
window.removeEventListener('resize', updateLocalMode);
}
});
</script>
<template>
<div class="admin-responsive-actions" @click.stop>
<div class="admin-responsive-actions__inline action-btns">
<div
ref="rootRef"
class="admin-responsive-actions"
:class="{ 'is-menu': useMenu }"
@click.stop
>
<div ref="inlineRef" class="admin-responsive-actions__inline action-btns">
<slot name="inline" />
</div>
<div class="admin-responsive-actions__menu">
@@ -18,6 +79,7 @@ import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
<style scoped>
.admin-responsive-actions {
width: 100%;
min-width: 0;
}
.admin-responsive-actions__menu {
@@ -26,12 +88,22 @@ import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
width: 100%;
}
.admin-responsive-actions.is-menu .admin-responsive-actions__inline {
display: none;
}
.admin-responsive-actions.is-menu .admin-responsive-actions__menu {
display: inline-flex;
}
.action-btns {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
gap: 2px 6px;
min-width: 0;
max-width: 100%;
}
.action-btns :deep(.el-button) {
@@ -41,15 +113,6 @@ import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
}
@media (max-width: 1280px) {
.admin-responsive-actions__inline {
display: none;
}
.admin-responsive-actions__menu {
display: inline-flex;
}
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useAdminTableRowActionsProvider } from '../composables/useAdminTableRowActionsLayout';
const wrapRef = ref<HTMLElement | null>(null);
const useMenu = useAdminTableRowActionsProvider(wrapRef);
</script>
<template>
<div
ref="wrapRef"
class="table-wrap"
:class="{ 'is-compact-row-actions': useMenu }"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useAdminLocale } from '../composables/useAdminLocale';
import type { InitialDepositRemarkKind } from '@thebet365/shared';
const kind = defineModel<InitialDepositRemarkKind | ''>('kind', { default: '' });
const custom = defineModel<string>('custom', { default: '' });
const props = defineProps<{
operator: 'admin' | 'agent';
}>();
const { t } = useAdminLocale();
</script>
<template>
<div class="initial-deposit-remark">
<el-radio-group v-model="kind" class="initial-deposit-remark-options">
<el-radio value="daily">{{ t('user.initial_deposit_kind.daily') }}</el-radio>
<el-radio value="opening_bonus">{{ t('user.initial_deposit_kind.opening_bonus') }}</el-radio>
<el-radio value="custom">{{ t('user.initial_deposit_kind.custom') }}</el-radio>
</el-radio-group>
<el-input
v-if="kind === 'custom'"
v-model="custom"
class="initial-deposit-remark-custom"
:placeholder="t('user.ph.initial_deposit_custom')"
/>
<div v-else-if="kind === 'daily'" class="field-hint">
{{ props.operator === 'admin' ? t('finance.remark.admin_deposit') : t('finance.remark.agent_deposit') }}
</div>
<div v-else-if="kind === 'opening_bonus'" class="field-hint">
{{ t('finance.remark.initial_balance') }}
</div>
</div>
</template>
<style scoped>
.initial-deposit-remark-options {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.initial-deposit-remark-custom {
margin-top: 8px;
}
</style>

View File

@@ -13,7 +13,7 @@ const emit = defineEmits<{
}>();
const { t } = useAdminLocale();
const activeTab = ref<'generate' | 'history'>('generate');
const activeTab = ref<'generate' | 'history'>('history');
const historyRefreshKey = ref(0);
const tabsBootKey = ref(0);
@@ -21,7 +21,7 @@ watch(
() => props.modelValue,
async (open) => {
if (!open) return;
activeTab.value = 'generate';
activeTab.value = 'history';
tabsBootKey.value += 1;
await nextTick();
historyRefreshKey.value += 1;
@@ -56,6 +56,12 @@ function onTabChange(name: string | number) {
class="invite-manage-tabs"
@tab-change="onTabChange"
>
<el-tab-pane :label="t('invite.tab_history')" name="history">
<div class="tab-pane-scroll">
<p class="tab-hint">{{ t('invite.history_hint') }}</p>
<InviteHistoryPanel embedded :refresh-key="historyRefreshKey" />
</div>
</el-tab-pane>
<el-tab-pane :label="t('invite.tab_generate')" name="generate">
<div class="tab-pane-scroll tab-pane-scroll--generate">
<div class="generate-tab-inner">
@@ -64,12 +70,6 @@ function onTabChange(name: string | number) {
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="t('invite.tab_history')" name="history">
<div class="tab-pane-scroll">
<p class="tab-hint">{{ t('invite.history_hint') }}</p>
<InviteHistoryPanel embedded :refresh-key="historyRefreshKey" />
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>

View File

@@ -0,0 +1,53 @@
import { ref, provide, inject, onMounted, onBeforeUnmount, nextTick, type Ref, type InjectionKey } from 'vue';
export const AdminTableRowActionsMenuKey: InjectionKey<Ref<boolean>> = Symbol('adminTableRowActionsMenu');
const MOBILE_MAX = 768;
export function useAdminTableRowActionsProvider(tableWrapRef: Ref<HTMLElement | null>) {
const useMenu = ref(false);
function update() {
const wrap = tableWrapRef.value;
if (!wrap) return;
if (window.innerWidth <= MOBILE_MAX) {
useMenu.value = true;
return;
}
const table = wrap.querySelector('.el-table') as HTMLElement | null;
if (!table) {
useMenu.value = false;
return;
}
useMenu.value = table.scrollWidth > wrap.clientWidth + 2;
}
let observer: ResizeObserver | null = null;
onMounted(async () => {
await nextTick();
update();
if (tableWrapRef.value) {
observer = new ResizeObserver(() => update());
observer.observe(tableWrapRef.value);
const table = tableWrapRef.value.querySelector('.el-table');
if (table) observer.observe(table);
}
window.addEventListener('resize', update);
});
onBeforeUnmount(() => {
observer?.disconnect();
window.removeEventListener('resize', update);
});
provide(AdminTableRowActionsMenuKey, useMenu);
return useMenu;
}
export function useAdminTableRowActionsMenu() {
return inject(AdminTableRowActionsMenuKey, null);
}

View File

@@ -12,7 +12,9 @@ export const adminPagesMs: Record<string, string> = {
'common.freeze': 'Bekukan',
'common.unfreeze': 'Nyahbeku',
'common.settle': 'Selesaikan',
'common.resettle': 'Selesaikan semula',
'common.close_betting': 'Tutup pertaruhan',
'common.reopen_betting': 'Buka semula pertaruhan',
'common.never_login': 'Belum pernah log masuk',
'common.optional': 'Pilihan',
'common.to': 'Hingga',
@@ -73,6 +75,10 @@ export const adminPagesMs: Record<string, string> = {
'user.reset_database_disabled_prod': 'Dilumpuhkan dalam produksi melainkan ALLOW_DB_RESET=true',
'user.reset_database_success': 'Pangkalan data diset semula. Sila log masuk semula.',
'user.reset_database_accounts': 'Akaun demo',
'user.section.basic_info': 'Maklumat asas',
'user.section.affiliation': 'Affiliasi',
'user.section.contact': 'Hubungan',
'user.section.account_overview': 'Gambaran akaun',
'user.section.password_mgmt': 'Pengurusan kata laluan',
'user.field.current_password': 'Kata laluan semasa',
'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {password}',
@@ -207,7 +213,13 @@ export const adminPagesMs: Record<string, string> = {
'finance.deposit_method.manual_agent': 'Deposit manual ejen',
'finance.deposit_method.manual': 'Deposit manual',
'finance.tx.deposit': 'Deposit',
'finance.tx.admin_deposit': 'Tambah baki admin',
'finance.tx.agent_deposit': 'Tambah baki ejen',
'finance.tx.initial_deposit': 'Bonus pembukaan',
'finance.tx.player_deposit': 'Deposit sendiri',
'finance.tx.withdraw': 'Pengeluaran',
'finance.tx.admin_withdraw': 'Pengeluaran admin',
'finance.tx.agent_withdraw': 'Pengeluaran ejen',
'finance.tx.request_id': 'ID permintaan',
'finance.remark.agent_deposit': 'Deposit ejen',
'finance.remark.agent_withdraw': 'Pengeluaran ejen',
@@ -613,6 +625,12 @@ export const adminPagesMs: Record<string, string> = {
'agent_portal.no_sub_agents': 'Tiada ejen peringkat 2. Klik butang di atas untuk cipta.',
'agent_portal.no_sub_agents_level': 'Tiada ejen peringkat {level}. Klik butang di atas untuk cipta.',
'agent_portal.sub_agent_players_readonly': 'Pemain langsung di bawah sub-ejen ini hanya boleh dilihat. Pembukaan akaun dan tambah baki diurus oleh sub-ejen.',
'agent_portal.sub_agent_downline_readonly': 'Semua ejen dan pemain bawahan di bawah sub-ejen ini hanya boleh dilihat. Anda hanya boleh mengendalikan sub-ejen langsung; pembukaan akaun dan tambah baki diurus oleh setiap peringkat.',
'agent_portal.sub_agent_downline_readonly_level': 'Semua ejen dan pemain di bawah ejen peringkat {level} ini hanya boleh dilihat. Anda hanya boleh mengendalikan sub-ejen langsung; pembukaan akaun dan tambah baki diurus oleh setiap peringkat.',
'agent_portal.downline_agents_title': 'Ejen bawahan',
'agent_portal.downline_players_title': 'Pemain bawahan',
'agent_portal.no_downline_agents': 'Tiada ejen bawahan',
'agent_portal.no_downline_players': 'Tiada pemain bawahan',
'agent_portal.sub_agent_players_readonly_level': 'Pemain langsung di bawah ejen peringkat {level} ini hanya boleh dilihat. Pembukaan akaun dan tambah baki diurus oleh ejen tersebut.',
'agent_portal.create_sub_agent_dialog': 'Ejen peringkat 2 baharu',
'agent_portal.sub_agent_credit_hint': 'Kredit awal diperuntukkan dari had tersedia anda',
@@ -634,6 +652,10 @@ export const adminPagesMs: Record<string, string> = {
'msg.match_created_draft': 'Perlawanan tunggal dicipta (draf)',
'msg.published': 'Diterbitkan dengan pasaran',
'msg.closed': 'Pertaruhan ditutup',
'msg.reopened': 'Pertaruhan dibuka semula',
'match.reopen_kickoff_title': 'Tetapkan masa mula baharu',
'match.reopen_kickoff_hint': 'Masa mula telah berlalu. Pilih masa mula baharu pada masa hadapan sebelum membuka semula.',
'match.reopen_kickoff_invalid': 'Sila pilih masa mula pada masa hadapan',
'msg.invalid_json': 'JSON tidak sah',
'msg.import_failed': 'Import gagal',
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',

View File

@@ -12,7 +12,9 @@ export const adminPagesZh: Record<string, string> = {
'common.freeze': '冻结',
'common.unfreeze': '解冻',
'common.settle': '结算',
'common.resettle': '重新结算',
'common.close_betting': '封盘',
'common.reopen_betting': '解除封盘',
'common.never_login': '从未登录',
'common.optional': '选填',
'common.to': '止',
@@ -27,6 +29,8 @@ export const adminPagesZh: Record<string, string> = {
'user.filter.agent_ph': '全部',
'user.col.username': '用户名',
'user.col.agent': '所属代理',
'user.col.agent_cashback': '代理返水率',
'user.col.player_cashback': '玩家返水率',
'user.col.invite_code': '邀请码',
'user.col.balance': '可用 / 冻结',
'user.col.bets': '注单',
@@ -43,6 +47,11 @@ export const adminPagesZh: Record<string, string> = {
'user.field.confirm_password': '确认密码',
'user.field.initial_balance': '初始余额',
'user.field.deposit_remark': '上分备注',
'user.field.initial_deposit_kind': '流水说明',
'user.initial_deposit_kind.daily': '日常充值',
'user.initial_deposit_kind.opening_bonus': '开户赠金',
'user.initial_deposit_kind.custom': '自定义',
'user.ph.initial_deposit_custom': '请输入流水说明(至少 2 个字符)',
'user.field.amount': '金额',
'user.field.remark': '备注',
'user.field.account_status': '账号状态',
@@ -73,6 +82,10 @@ export const adminPagesZh: Record<string, string> = {
'user.reset_database_disabled_prod': '生产环境已禁用;需服务端设置 ALLOW_DB_RESET=true',
'user.reset_database_success': '数据库已重置,请使用初始账号重新登录',
'user.reset_database_accounts': '演示账号',
'user.section.basic_info': '基本信息',
'user.section.affiliation': '归属设置',
'user.section.contact': '联系方式',
'user.section.account_overview': '账户概览',
'user.section.password_mgmt': '密码管理',
'user.field.current_password': '当前密码',
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
@@ -84,7 +97,7 @@ export const adminPagesZh: Record<string, string> = {
'user.ph.no_agent': '不设置(平台直属玩家)',
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
'user.hint.platform_direct_player': '该玩家隶属于平台(管理员直属)',
'user.hint.initial_balance': '创建后自动上分0 表示不开户赠金',
'user.hint.initial_balance': '创建后自动上分0 表示不开户上分',
'user.hint.deposit_remark': '有初始余额时写入流水备注',
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
@@ -211,7 +224,12 @@ export const adminPagesZh: Record<string, string> = {
'finance.deposit_method.manual_agent': '代理人工充值',
'finance.deposit_method.manual': '人工充值',
'finance.tx.deposit': '充值',
'finance.tx.admin_deposit': '管理员上分',
'finance.tx.agent_deposit': '代理上分',
'finance.tx.player_deposit': '自助充值',
'finance.tx.withdraw': '下分',
'finance.tx.admin_withdraw': '管理员下分',
'finance.tx.agent_withdraw': '代理下分',
'finance.tx.request_id': '请求 ID',
'finance.remark.agent_deposit': '代理上分',
'finance.remark.agent_withdraw': '代理下分',
@@ -502,6 +520,8 @@ export const adminPagesZh: Record<string, string> = {
'err.user_required': '请选择用户',
'err.agent_no_parent': '一级代理不可设置上级玩家',
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
'err.initial_deposit_kind_required': '有初始余额时请选择上分流水说明',
'err.initial_deposit_custom_required': '自定义流水说明至少 2 个字符',
'settlement.back': '返回赛事列表',
'settlement.kickoff': '开赛时间',
@@ -618,8 +638,15 @@ export const adminPagesZh: Record<string, string> = {
'credit.context.acting_agent': '当前代理',
'agent_portal.create_player_dialog': '新建直属玩家',
'agent_portal.edit_player_dialog': '编辑直属玩家',
'agent_portal.my_cashback_rate': '反水比例',
'agent_portal.credit_available_hint': '当前可用授信:{amount}(上分将从授信中扣除)',
'agent_portal.sub_agent_players_readonly': '以下为该二级代理直属玩家,仅可查看;开户、上分等操作由二级代理自行处理。',
'agent_portal.sub_agent_downline_readonly': '以下为该二级代理下级所有代理与玩家,仅可查看;您只能操作直属二级代理,开户、上分等由各级代理自行处理。',
'agent_portal.sub_agent_downline_readonly_level': '以下为该{level}级代理下级所有代理与玩家,仅可查看;您只能操作直属下级代理,开户、上分等由各级代理自行处理。',
'agent_portal.downline_agents_title': '下级代理',
'agent_portal.downline_players_title': '下级玩家',
'agent_portal.no_downline_agents': '暂无下级代理',
'agent_portal.no_downline_players': '暂无下级玩家',
'agent_portal.initial_deposit_hint': '可选。开户时从您的授信中给玩家上分,不能超过可用授信',
'agent_portal.search_player_ph': '用户名或 ID',
'agent_portal.no_players': '暂无直属玩家,点击右上角创建',
@@ -698,6 +725,10 @@ export const adminPagesZh: Record<string, string> = {
'msg.match_created_draft': '单场已创建(草稿)',
'msg.published': '已发布并生成盘口',
'msg.closed': '已封盘',
'msg.reopened': '已解除封盘',
'match.reopen_kickoff_title': '设置新的开赛时间',
'match.reopen_kickoff_hint': '开赛时间已过,请选择新的未来开赛时间后再解除封盘。',
'match.reopen_kickoff_invalid': '请选择未来的开赛时间',
'msg.invalid_json': 'JSON 格式无效',
'msg.import_failed': '导入失败',
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
@@ -951,7 +982,9 @@ export const adminPagesEn: Record<string, string> = {
'common.freeze': 'Freeze',
'common.unfreeze': 'Unfreeze',
'common.settle': 'Settle',
'common.resettle': 'Resettle',
'common.close_betting': 'Close',
'common.reopen_betting': 'Reopen betting',
'common.never_login': 'Never signed in',
'common.optional': 'Optional',
'common.to': 'To',
@@ -966,6 +999,8 @@ export const adminPagesEn: Record<string, string> = {
'user.filter.agent_ph': 'All',
'user.col.username': 'Username',
'user.col.agent': 'Agent',
'user.col.agent_cashback': 'Agent cashback',
'user.col.player_cashback': 'Player cashback',
'user.col.invite_code': 'Invite code',
'user.col.balance': 'Available / Frozen',
'user.col.bets': 'Bets',
@@ -982,6 +1017,11 @@ export const adminPagesEn: Record<string, string> = {
'user.field.confirm_password': 'Confirm password',
'user.field.initial_balance': 'Initial balance',
'user.field.deposit_remark': 'Top-up note',
'user.field.initial_deposit_kind': 'Ledger note',
'user.initial_deposit_kind.daily': 'Regular top-up',
'user.initial_deposit_kind.opening_bonus': 'Opening bonus',
'user.initial_deposit_kind.custom': 'Custom',
'user.ph.initial_deposit_custom': 'Enter ledger note (min. 2 characters)',
'user.field.amount': 'Amount',
'user.field.remark': 'Note',
'user.field.account_status': 'Account status',
@@ -1012,6 +1052,10 @@ export const adminPagesEn: Record<string, string> = {
'user.reset_database_disabled_prod': 'Disabled in production unless ALLOW_DB_RESET=true is set on the server',
'user.reset_database_success': 'Database reset complete. Sign in again with demo accounts.',
'user.reset_database_accounts': 'Demo accounts',
'user.section.basic_info': 'Basic info',
'user.section.affiliation': 'Affiliation',
'user.section.contact': 'Contact',
'user.section.account_overview': 'Account overview',
'user.section.password_mgmt': 'Password management',
'user.field.current_password': 'Current password',
'user.msg.created_with_password': 'Player created. Login password: {password}',
@@ -1023,7 +1067,7 @@ export const adminPagesEn: Record<string, string> = {
'user.ph.no_agent': 'None (platform direct)',
'user.hint.no_agent': 'Leave empty for platform-managed player',
'user.hint.platform_direct_player': 'This player belongs to the platform (admin direct).',
'user.hint.initial_balance': 'Auto top-up on create; 0 = no bonus',
'user.hint.initial_balance': 'Auto top-up on create; 0 = no initial top-up',
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
@@ -1150,7 +1194,12 @@ export const adminPagesEn: Record<string, string> = {
'finance.deposit_method.manual_agent': 'Agent manual deposit',
'finance.deposit_method.manual': 'Manual deposit',
'finance.tx.deposit': 'Deposit',
'finance.tx.admin_deposit': 'Admin top-up',
'finance.tx.agent_deposit': 'Agent top-up',
'finance.tx.player_deposit': 'Self deposit',
'finance.tx.withdraw': 'Withdraw',
'finance.tx.admin_withdraw': 'Admin withdraw',
'finance.tx.agent_withdraw': 'Agent withdraw',
'finance.tx.request_id': 'Request ID',
'finance.remark.agent_deposit': 'Agent deposit',
'finance.remark.agent_withdraw': 'Agent withdraw',
@@ -1441,6 +1490,8 @@ export const adminPagesEn: Record<string, string> = {
'err.user_required': 'Please select a user',
'err.agent_no_parent': 'Tier-1 agents cannot have a parent player',
'err.agent_no_initial_deposit': 'Do not set initial player balance when creating an agent',
'err.initial_deposit_kind_required': 'Select a ledger note when initial balance > 0',
'err.initial_deposit_custom_required': 'Custom ledger note must be at least 2 characters',
'settlement.back': 'Back to matches',
'settlement.kickoff': 'Kick-off',
@@ -1558,8 +1609,15 @@ export const adminPagesEn: Record<string, string> = {
'credit.context.acting_agent': 'Current agent',
'agent_portal.create_player_dialog': 'New direct player',
'agent_portal.edit_player_dialog': 'Edit direct player',
'agent_portal.my_cashback_rate': 'Cashback rate',
'agent_portal.credit_available_hint': 'Available credit: {amount} (top-ups deduct from your limit)',
'agent_portal.sub_agent_players_readonly': 'Players under this sub-agent are read-only here. Account opening and top-ups are handled by the sub-agent.',
'agent_portal.sub_agent_downline_readonly': 'All subordinate agents and players below this sub-agent are read-only. You may only operate direct sub-agents; account opening and top-ups are handled by each level.',
'agent_portal.sub_agent_downline_readonly_level': 'All agents and players below this L{level} agent are read-only. You may only operate direct sub-agents; account opening and top-ups are handled by each level.',
'agent_portal.downline_agents_title': 'Subordinate agents',
'agent_portal.downline_players_title': 'Subordinate players',
'agent_portal.no_downline_agents': 'No subordinate agents',
'agent_portal.no_downline_players': 'No subordinate players',
'agent_portal.initial_deposit_hint': 'Optional. Initial top-up from your credit at account creation',
'agent_portal.search_player_ph': 'Username or ID',
'agent_portal.no_players': 'No direct players yet. Use the button above to create one.',
@@ -1638,6 +1696,10 @@ export const adminPagesEn: Record<string, string> = {
'msg.match_created_draft': 'Fixture created (draft)',
'msg.published': 'Published with markets',
'msg.closed': 'Betting closed',
'msg.reopened': 'Betting reopened',
'match.reopen_kickoff_title': 'Set new kickoff time',
'match.reopen_kickoff_hint': 'Kickoff has passed. Choose a new future start time before reopening.',
'match.reopen_kickoff_invalid': 'Please choose a future kickoff time',
'msg.invalid_json': 'Invalid JSON',
'msg.import_failed': 'Import failed',
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',

View File

@@ -4,6 +4,7 @@ import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
import AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
const route = useRoute();
@@ -15,24 +16,24 @@ const sidebarOpen = ref(false);
const isMobileNav = ref(false);
const adminMenus = computed(() => [
{ path: '/', label: t('nav.dashboard'), matchPrefix: true },
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
{ path: '/users', label: t('nav.agents_players') },
{ path: '/finance-logs', label: t('nav.finance_logs') },
{ path: '/deposit', label: t('nav.deposit_manage'), matchPrefix: true },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/bets', label: t('nav.bets') },
{ path: '/contents', label: t('nav.contents') },
{ path: '/media', label: t('nav.media') },
{ path: '/audit', label: t('nav.audit') },
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true },
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true },
{ path: '/users', label: t('nav.agents_players'), icon: 'users' },
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance' },
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true },
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback' },
{ path: '/bets', label: t('nav.bets'), icon: 'bets' },
{ path: '/contents', label: t('nav.contents'), icon: 'contents' },
{ path: '/media', label: t('nav.media'), icon: 'media' },
{ path: '/audit', label: t('nav.audit'), icon: 'audit' },
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests' },
]);
const agentMenus = computed(() => [
{ path: '/', label: t('nav.dashboard') },
{ path: '/my-players', label: t('nav.agents_players') },
{ path: '/finance-logs', label: t('nav.finance_logs') },
{ path: '/my-bets', label: t('nav.myBets') },
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard' },
{ path: '/my-players', label: t('nav.agents_players'), icon: 'users' },
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance' },
{ path: '/my-bets', label: t('nav.myBets'), icon: 'bets' },
]);
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
@@ -159,7 +160,8 @@ watch(() => route.path, () => {
}"
@click="onNavClick"
>
{{ m.label }}
<AdminNavIcon :name="m.icon" />
<span class="nav-label">{{ m.label }}</span>
</RouterLink>
</nav>
@@ -272,6 +274,7 @@ watch(() => route.path, () => {
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
color: #737373;
@@ -280,15 +283,29 @@ watch(() => route.path, () => {
transition: color 0.15s, background 0.15s;
letter-spacing: 0;
}
.nav-item :deep(.admin-nav-icon) {
opacity: 0.72;
transition: opacity 0.15s;
}
.nav-label {
min-width: 0;
line-height: 1.25;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: #d4d4d4;
}
.nav-item:hover :deep(.admin-nav-icon) {
opacity: 0.92;
}
.nav-item.active {
background: rgba(255, 255, 255, 0.06);
color: #f5f5f5;
font-weight: 500;
}
.nav-item.active :deep(.admin-nav-icon) {
opacity: 1;
}
.sidebar-foot {
padding: 10px 10px;

View File

@@ -0,0 +1,21 @@
import {
type InitialDepositRemarkKind,
type InitialDepositOperator,
resolveInitialDepositRemark,
} from '@thebet365/shared';
import { FormValidationError } from '../i18n/form-validation';
export function buildInitialDepositRemark(
initialDeposit: number,
kind: InitialDepositRemarkKind | '',
custom: string,
operator: InitialDepositOperator,
): string | undefined {
if (initialDeposit <= 0) return undefined;
if (!kind) throw new FormValidationError('err.initial_deposit_kind_required');
try {
return resolveInitialDepositRemark(kind, custom, operator);
} catch {
throw new FormValidationError('err.initial_deposit_custom_required');
}
}

View File

@@ -1,6 +1,11 @@
export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'finance.tx.deposit',
ADMIN_DEPOSIT: 'finance.tx.admin_deposit',
AGENT_DEPOSIT: 'finance.tx.agent_deposit',
INITIAL_DEPOSIT: 'finance.tx.admin_deposit',
MANUAL_WITHDRAW: 'finance.tx.withdraw',
ADMIN_WITHDRAW: 'finance.tx.admin_withdraw',
AGENT_WITHDRAW: 'finance.tx.agent_withdraw',
MANUAL_ADJUST: 'finance.tx.adjust',
BET_FREEZE: 'finance.tx.bet_freeze',
BET_DEDUCT: 'finance.tx.bet_deduct',
@@ -16,7 +21,7 @@ export const TX_KEY_MAP: Record<string, string> = {
RESETTLE_REVERSE: 'finance.tx.resettle',
DEPOSIT: 'finance.tx.deposit',
WITHDRAW: 'finance.tx.withdraw',
PLAYER_DEPOSIT: 'finance.tx.deposit',
PLAYER_DEPOSIT: 'finance.tx.player_deposit',
};
export function walletTxTypeKey(type: string): string {
@@ -25,6 +30,9 @@ export function walletTxTypeKey(type: string): string {
export const DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
@@ -49,5 +57,10 @@ export function walletDepositMethodLabel(
const key = DEPOSIT_METHOD_KEY_MAP[row.depositMethodKey];
return key ? t(key) : row.depositMethodKey;
}
const type = row.transactionType.toUpperCase();
if (type === 'ADMIN_DEPOSIT') return t('finance.tx.admin_deposit');
if (type === 'AGENT_DEPOSIT') return t('finance.tx.agent_deposit');
if (type === 'INITIAL_DEPOSIT') return t('finance.tx.admin_deposit');
if (type === 'PLAYER_DEPOSIT') return t('finance.tx.player_deposit');
return '—';
}

View File

@@ -45,12 +45,14 @@ import {
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../components/InitialDepositRemarkField.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminRowActionsDropdown from '../components/AdminRowActionsDropdown.vue';
import AdminTableWrap from '../components/AdminTableWrap.vue';
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
@@ -83,11 +85,6 @@ type SubAgentLevelState = {
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
function setSubAgentTableRef(level: number, el: unknown) {
subAgentTableRefs.value[level] = el as { toggleRowExpansion: (row: AgentRow) => void } | null;
}
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
@@ -155,7 +152,8 @@ const agentOptions = ref<{ id: string; username: string; level: number; parentUs
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const tier1AgentTableRef = ref();
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const createToolbarChildLevel = ref<number | null>(null);
@@ -402,17 +400,23 @@ onMounted(() => {
/* ─── Load tier-1 agents ─── */
async function loadTier1Agents() {
const { data } = await api.get('/admin/agents', {
params: {
page: tier1Page.value,
pageSize: tier1PageSize.value,
keyword: tier1Keyword.value.trim() || undefined,
status: tier1FilterStatus.value || undefined,
level: 1,
},
});
tier1Agents.value = data.data.items as AgentRow[];
tier1Total.value = data.data.total;
try {
const { data } = await api.get('/admin/agents', {
params: {
page: tier1Page.value,
pageSize: tier1PageSize.value,
keyword: tier1Keyword.value.trim() || undefined,
status: tier1FilterStatus.value || undefined,
level: 1,
},
});
tier1Agents.value = data.data.items as AgentRow[];
tier1Total.value = data.data.total;
} catch (e) {
tier1Agents.value = [];
tier1Total.value = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onTier1PageChange(p: number) {
@@ -447,17 +451,23 @@ async function loadAgentLevelCounts() {
async function loadSubAgentsAtLevel(level: number) {
const st = ensureSubAgentState(level);
const { data } = await api.get('/admin/agents', {
params: {
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
try {
const { data } = await api.get('/admin/agents', {
params: {
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
} catch (e) {
st.agents = [];
st.total = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onSubAgentPageChange(level: number, p: number) {
@@ -560,18 +570,11 @@ function directPlayersTabLabel(ownerName: string, count: number) {
}
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, tier1AgentTableRef, _column, event);
onAgentRowClick(row, event);
}
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRefs.value[level]?.toggleRowExpansion(row);
}
function bindSubAgentRowClick(level: number) {
return (row: AgentRow, column: unknown, event: MouseEvent) => {
onSubAgentRowClick(level, row, column, event);
};
function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
}
watch(activeViewTab, (tab) => {
@@ -595,9 +598,17 @@ async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRo
}
}
function onAgentRowClick(row: AgentRow, tableRef: typeof tier1AgentTableRef, _column: unknown, event: MouseEvent) {
function onAgentRowClick(row: AgentRow, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
tableRef.value?.toggleRowExpansion(row);
const userId = row.userId;
const next = new Set(expandedSet.value);
if (next.has(userId)) {
next.delete(userId);
} else {
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentId: string) {
@@ -1360,7 +1371,7 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table v-loading="playerLoading" :data="allPlayers" stripe>
<template #empty>
<AdminTableEmpty />
@@ -1396,7 +1407,7 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="360" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="340" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
:row="row"
@@ -1410,7 +1421,7 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="playerPage"
@@ -1448,12 +1459,12 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table
ref="tier1AgentTableRef"
:data="tier1Agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@@ -1544,24 +1555,22 @@ function creditTypeLabel(type: string) {
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
:row="{ userId: row.userId, status: row.status }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="tier1Page"
@@ -1617,16 +1626,16 @@ function creditTypeLabel(type: string) {
</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table
:ref="(el: unknown) => setSubAgentTableRef(agentLevel, el)"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="bindSubAgentRowClick(agentLevel)"
@row-click="onSubAgentRowClick"
>
<template #empty>
<AdminTableEmpty />
@@ -1696,24 +1705,22 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
:current-page="ensureSubAgentState(agentLevel).page"
@@ -1908,8 +1915,15 @@ function creditTypeLabel(type: string) {
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
<el-form-item
v-if="createForm.initialDeposit > 0"
:label="t('user.field.initial_deposit_kind')"
>
<InitialDepositRemarkField
v-model:kind="createForm.initialDepositRemarkKind"
v-model:custom="createForm.initialDepositRemarkCustom"
operator="admin"
/>
</el-form-item>
</template>
@@ -1931,61 +1945,90 @@ function creditTypeLabel(type: string) {
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="480px" destroy-on-close class="user-edit-dialog">
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close class="user-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editPlayerForm.id }}</span>
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.basic_info') }}</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<p v-else class="field-hint block-hint">
{{ t('cashback.use_default_rate', { rate: `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }) }}
</p>
<div class="edit-form-section">
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
<span v-if="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.affiliation') }}</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<span v-else class="field-hint inline-hint">
{{ `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }}
</span>
</div>
</el-form-item>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.contact') }}</div>
<el-row :gutter="12" class="contact-row">
<el-col :span="12">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="edit-form-section edit-stats-panel">
<div class="section-title">{{ t('user.section.account_overview') }}</div>
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editPlayerVisible = false">{{ t('common.cancel') }}</el-button>
@@ -2132,11 +2175,7 @@ function creditTypeLabel(type: string) {
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('agent.field.cashback_rate')">
{{
playerDetail.customCashbackRate != null
? formatRatePercent(playerDetail.customCashbackRate)
: t('cashback.use_default_rate', { rate: formatRatePercent(playerDetail.defaultCashbackRate) })
}}
{{ formatRatePercent(playerDetail.customCashbackRate ?? playerDetail.defaultCashbackRate) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
@@ -2161,11 +2200,6 @@ function creditTypeLabel(type: string) {
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
</el-button>
</div>
</template>
</el-dialog>
@@ -2571,41 +2605,6 @@ function creditTypeLabel(type: string) {
}
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
.password-mgmt-block {
margin: 4px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.password-plain {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
letter-spacing: 0.06em;
}
.password-empty { color: #666; }
.block-hint { margin: -4px 0 8px; }
.edit-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 12px;
color: #888;
}
.compact-edit-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.edit-stats { margin-top: 4px; }
.list-settings-block--danger {
margin-top: 12px;
padding-top: 12px;

View File

@@ -174,6 +174,7 @@ onMounted(fetchList);
<th>{{ t('common.status') }}</th>
<th>{{ t('deposit.approved_amount') }}</th>
<th>{{ t('deposit.reviewer') }}</th>
<th>{{ t('user.field.remark') }}</th>
<th>{{ t('deposit.time') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
@@ -195,15 +196,13 @@ onMounted(fetchList);
<td><span :class="statusClass(row.status)">{{ statusLabel(row.status) }}</span></td>
<td>{{ row.approvedAmount ? formatAmount(row.approvedAmount) : '-' }}</td>
<td>{{ row.reviewerUsername || '-' }}</td>
<td class="remark-cell">{{ row.remark || row.rejectReason || '-' }}</td>
<td class="time-cell">{{ new Date(row.createdAt).toLocaleString() }}</td>
<td>
<template v-if="row.status === 'PENDING'">
<button class="btn-sm btn-approve" @click="openApprove(row)">{{ t('deposit.approve') }}</button>
<button class="btn-sm btn-reject" @click="openReject(row)">{{ t('deposit.reject') }}</button>
</template>
<template v-else-if="row.status === 'REJECTED'">
<span class="reject-reason" :title="row.rejectReason || ''">{{ row.rejectReason }}</span>
</template>
</td>
</tr>
</tbody>
@@ -293,6 +292,7 @@ onMounted(fetchList);
.mono { font-family: monospace; font-size: 11px; }
.amount { font-weight: 700; }
.time-cell { font-size: 11px; color: #888; }
.remark-cell { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #aaa; }
.screenshot-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #444; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-blue { background: #1e3a5f; color: #66b1ff; }
@@ -305,7 +305,6 @@ onMounted(fetchList);
.btn-approve:hover { background: rgba(61, 115, 88, 0.24); }
.btn-reject { background: #3a1a1a; color: #f56c6c; }
.btn-reject:hover { background: #4a2525; }
.reject-reason { font-size: 11px; color: #f56c6c; max-width: 120px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; }
.pagination button { background: #333; color: #ddd; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }

View File

@@ -30,6 +30,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../components/InitialDepositRemarkField.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
const users = ref<PlayerRow[]>([]);
@@ -667,8 +668,15 @@ function statusLabel(s: string) {
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
<el-form-item
v-if="createForm.initialDeposit > 0"
:label="t('user.field.initial_deposit_kind')"
>
<InitialDepositRemarkField
v-model:kind="createForm.initialDepositRemarkKind"
v-model:custom="createForm.initialDepositRemarkCustom"
operator="admin"
/>
</el-form-item>
</template>
</el-form>
@@ -681,7 +689,7 @@ function statusLabel(s: string) {
<el-dialog
v-model="editVisible"
:title="t('user.dialog.edit')"
width="480px"
width="560px"
destroy-on-close
class="user-edit-dialog"
>
@@ -691,59 +699,82 @@ function statusLabel(s: string) {
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">
{{ t('user.hint.password_reset_to_view') }}
</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input
v-model="editForm.newPassword"
type="text"
autocomplete="off"
:placeholder="t('user.ph.reset_password_short')"
/>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.basic_info') }}</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info">{{ playerAffiliationLabel(editForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<div class="edit-form-section">
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">
{{ t('user.hint.password_reset_to_view') }}
</p>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
<el-input
v-model="editForm.newPassword"
type="text"
autocomplete="off"
:placeholder="t('user.ph.reset_password_short')"
/>
</div>
</div>
</div>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">
{{ formatAmount(editForm.availableBalance) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
{{ formatAmount(editForm.frozenBalance) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">
{{ formatAmount(editForm.totalReturn) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.affiliation') }}</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info">{{ playerAffiliationLabel(editForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.contact') }}</div>
<el-row :gutter="12" class="contact-row">
<el-col :span="12">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="edit-form-section edit-stats-panel">
<div class="section-title">{{ t('user.section.account_overview') }}</div>
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(editForm.availableBalance) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">
{{ formatAmount(editForm.frozenBalance) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">
{{ formatAmount(editForm.totalReturn) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
@@ -845,47 +876,6 @@ function statusLabel(s: string) {
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
.password-mgmt-block {
margin: 4px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.password-plain {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
letter-spacing: 0.06em;
}
.password-empty {
color: #666;
}
.block-hint {
margin: -4px 0 8px;
}
.edit-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 12px;
color: #888;
}
.compact-edit-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.edit-stats {
margin-top: 4px;
}
.list-settings-block--danger {
margin-top: 12px;
padding-top: 12px;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -29,13 +29,16 @@ import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../../utils/expandable-table';
import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../../components/InitialDepositRemarkField.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import InviteManageDialog from '../../components/InviteManageDialog.vue';
import AdminTableWrap from '../../components/AdminTableWrap.vue';
import AdminAgentRowActions from '../../components/AdminAgentRowActions.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import AdminRowActionsDropdown from '../../components/AdminRowActionsDropdown.vue';
import AdminDetailGrid from '../../components/AdminDetailGrid.vue';
import AdminDetailItem from '../../components/AdminDetailItem.vue';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -46,6 +49,15 @@ import {
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
import { formatRatePercent } from '../../utils/rate-percent';
type ScopedPlayerRow = AgentPlayerRow & {
parentAgentId?: string | null;
parentAgentUsername?: string;
cashbackRate?: string;
inChain?: boolean;
isDirect?: boolean;
};
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
@@ -53,9 +65,11 @@ const inviteDialogOpen = ref(false);
const profile = ref<{
level?: number;
maxAgentLevel?: number;
creditLimit?: string;
usedCredit?: string;
availableCredit?: string;
cashbackRate?: string;
canManageSubAgents?: boolean;
}>({});
@@ -63,19 +77,53 @@ const canManageSubAgents = computed(
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
);
const myAgentId = computed(() => String(auth.user.value?.id ?? ''));
const myAgentLevel = computed(() => profile.value.level ?? auth.user.value?.agentLevel ?? 1);
const childAgentLevel = computed(() => myAgentLevel.value + 1);
const hierarchySettings = computed(() => ({ maxAgentLevel: profile.value.maxAgentLevel ?? 0 }));
function lvlLabel(level: number) {
return formatAgentLevelNumeral(level, localeTag.value);
}
const childAgentTierName = computed(() =>
t('agent.level_name', { level: lvlLabel(childAgentLevel.value) }),
);
function agentTierName(level: number) {
return t('agent.level_name', { level: lvlLabel(level) });
}
const subAgentsTabLabel = computed(() =>
`${childAgentTierName.value} (${subAgents.value.length})`,
function agentLevelTabName(level: number) {
return `agentLevel-${level}`;
}
const agentLevelCounts = ref<Record<number, number>>({});
function agentLevelTabLabel(level: number) {
const count = agentLevelCounts.value[level] ?? 0;
return `${agentTierName(level)} (${count})`;
}
const visibleSubAgentTabLevels = computed(() => {
if (!canManageSubAgents.value) return [] as number[];
const counts = agentLevelCounts.value;
const max = hierarchySettings.value.maxAgentLevel;
const start = childAgentLevel.value;
const levels = new Set<number>([start]);
if (max === 0) {
for (const [lvl, cnt] of Object.entries(counts)) {
const n = Number(lvl);
if (n > myAgentLevel.value && cnt > 0) levels.add(n);
}
} else {
for (let n = start + 1; n <= max; n++) {
if (n > myAgentLevel.value && (counts[n] ?? 0) > 0) levels.add(n);
}
}
return [...levels].sort((a, b) => a - b);
});
const scopedAgentOptions = computed(() =>
agentOptions.value.filter(
(a) => a.id === myAgentId.value || a.level > myAgentLevel.value,
),
);
const createSubBtnLabel = computed(() => {
@@ -88,23 +136,55 @@ const createSubDialogTitle = computed(() => {
return t('agent.dialog.create_level_agent', { level: lvlLabel(childAgentLevel.value) });
});
const noSubAgentsHint = computed(() => {
if (childAgentLevel.value === 2) return t('agent_portal.no_sub_agents');
return t('agent_portal.no_sub_agents_level', { level: lvlLabel(childAgentLevel.value) });
});
const expandReadonlyHint = computed(() =>
t('agent_portal.sub_agent_downline_readonly_level', { level: lvlLabel(childAgentLevel.value) }),
);
const subAgentPlayersReadonlyHint = computed(() => {
if (childAgentLevel.value === 2) return t('agent_portal.sub_agent_players_readonly');
return t('agent_portal.sub_agent_players_readonly_level', { level: lvlLabel(childAgentLevel.value) });
});
/* ─── View tab: players | agentLevel-N ─── */
const activeViewTab = ref('players');
/* ─── Top-level tab: players | subAgents ─── */
const activeTab = ref('players');
/* ─── Players ─── */
const players = ref<AgentPlayerRow[]>([]);
/* ─── All players (subtree) ─── */
const allPlayers = ref<ScopedPlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(20);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
const loadingPlayers = ref(false);
const keyword = ref('');
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Sub-agent lists by level ─── */
type SubAgentLevelState = {
agents: AgentSubAgentRow[];
total: number;
page: number;
pageSize: number;
keyword: string;
filterStatus: string;
};
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
agents: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
filterStatus: '',
};
}
return subAgentLevelState[level];
}
/* ─── Agent row expansion (direct players) ─── */
const expandedSet = ref(new Set<string>());
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const createVisible = ref(false);
const createLoading = ref(false);
@@ -117,23 +197,11 @@ const editForm = ref(emptyAgentPlayerEditForm());
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<AgentPlayerRow | null>(null);
const transferTarget = ref<ScopedPlayerRow | null>(null);
const transferAmount = ref(100);
const transferContext = ref<WalletTransferContextData | null>(null);
const transferContextLoading = ref(false);
/* ─── Sub-agents ─── */
const subAgentKeyword = ref('');
const subAgents = ref<AgentSubAgentRow[]>([]);
const loadingAgents = ref(false);
const subAgentTableRef = ref<TableInstance>();
/* Sub-agent expansion */
const expandedSet = ref(new Set<string>());
const subAgentPlayersMap = ref<Record<string, AgentPlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
/* Sub-agent create dialog */
const createSubVisible = ref(false);
const createSubLoading = ref(false);
const createSubForm = ref(emptyAgentSubAgentCreateForm());
@@ -157,21 +225,28 @@ const availableCreditNum = computed(() => {
const initialDepositRange = computed(() => ({ min: 0, max: availableCreditNum.value }));
const filteredPlayers = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return players.value;
return players.value.filter(
(p) => p.username.toLowerCase().includes(q) || String(p.id).includes(q),
);
});
const playerTabLabel = computed(() => `${t('user.type.player')} (${playerTotal.value})`);
const filteredSubAgents = computed(() => {
const q = subAgentKeyword.value.trim().toLowerCase();
if (!q) return subAgents.value;
return subAgents.value.filter(
(a) => a.username.toLowerCase().includes(q) || a.userId.includes(q),
);
});
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
return `L${a.level} ${chain} (#${a.id})`;
}
function isDirectChildAgent(row: AgentSubAgentRow) {
return row.parentAgentId === myAgentId.value;
}
function canViewPlayer(row: ScopedPlayerRow) {
return row.inChain !== false;
}
function canOperatePlayer(row: ScopedPlayerRow) {
return row.isDirect === true && canViewPlayer(row);
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
}
const transferAmountRange = computed(() => {
if (transferType.value === 'withdraw') {
@@ -207,9 +282,23 @@ const transferAmountCapError = computed(() => {
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
await loadPlayers();
await loadAgentOptions();
await loadAllPlayers();
if (canManageSubAgents.value) {
await loadSubAgents();
await reloadSubAgentTabs();
}
});
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);
}
}
});
@@ -220,70 +309,175 @@ async function loadProfile() {
} catch { /* empty */ }
}
async function loadPlayers() {
async function loadAgentOptions() {
try {
const { data } = await api.get('/agent/agents/options');
agentOptions.value = data.data ?? [];
} catch {
agentOptions.value = [];
}
}
async function loadAllPlayers() {
loadingPlayers.value = true;
try {
const { data } = await api.get('/agent/players');
players.value = data.data as AgentPlayerRow[];
const { data } = await api.get('/agent/players/scoped', {
params: {
page: playerPage.value,
pageSize: playerPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
status: playerFilterStatus.value || undefined,
parentAgentId: playerFilterAgent.value || undefined,
},
});
allPlayers.value = (data.data.items ?? []) as ScopedPlayerRow[];
playerTotal.value = data.data.total ?? 0;
} catch {
allPlayers.value = [];
playerTotal.value = 0;
} finally {
loadingPlayers.value = false;
}
}
function searchPlayers() {
playerPage.value = 1;
loadAllPlayers();
}
function onPlayerPageChange(p: number) {
playerPage.value = p;
loadAllPlayers();
}
function onPlayerSizeChange(size: number) {
playerPageSize.value = size;
playerPage.value = 1;
loadAllPlayers();
}
async function loadAgentLevelCounts() {
try {
const { data } = await api.get('/agent/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);
try {
const { data } = await api.get('/agent/agents/by-level', {
params: {
level,
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
},
});
st.agents = (data.data.items ?? []) as AgentSubAgentRow[];
st.total = data.data.total ?? 0;
} catch {
st.agents = [];
st.total = 0;
}
}
function onSubAgentPageChange(level: number, p: number) {
ensureSubAgentState(level).page = p;
loadSubAgentsAtLevel(level);
}
function onSubAgentSizeChange(level: number, size: number) {
const st = ensureSubAgentState(level);
st.pageSize = size;
st.page = 1;
loadSubAgentsAtLevel(level);
}
function bindSubAgentPageChange(level: number) {
return (p: number) => onSubAgentPageChange(level, p);
}
function bindSubAgentSizeChange(level: number) {
return (size: number) => onSubAgentSizeChange(level, size);
}
function searchSubAgentsAtLevel(level: number) {
ensureSubAgentState(level).page = 1;
loadSubAgentsAtLevel(level);
}
async function reloadSubAgentTabs() {
await loadAgentLevelCounts();
for (const lvl of visibleSubAgentTabLevels.value) {
await loadSubAgentsAtLevel(lvl);
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
function openPlayerWalletLedger(
playerId: string,
playerUsername?: string | null,
row?: ScopedPlayerRow,
) {
if (row && !canViewPlayer(row)) return;
walletLedgerPlayerId.value = playerId;
walletLedgerPlayerUsername.value = playerUsername ?? null;
walletLedgerVisible.value = true;
}
async function loadSubAgents() {
loadingAgents.value = true;
try {
const { data } = await api.get('/agent/agents');
const items = data.data;
if (Array.isArray(items)) {
subAgents.value = items as AgentSubAgentRow[];
} else {
subAgents.value = [];
}
} catch {
subAgents.value = [];
} finally {
loadingAgents.value = false;
}
}
/* ─── Sub-agent expansion ─── */
async function onSubAgentExpand(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map(r => r.userId));
if (expandedSet.value.has(row.userId) && !subAgentPlayersMap.value[row.userId]) {
await loadSubAgentPlayers(row.userId);
/* ─── Agent row expansion ─── */
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRef.value?.toggleRowExpansion(row);
const userId = row.userId;
const next = new Set(expandedSet.value);
if (next.has(userId)) {
next.delete(userId);
} else {
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
async function loadExpansionData(agentUserId: string) {
expandLoading.value[agentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${subAgentUserId}/players`);
subAgentPlayersMap.value[subAgentUserId] = data.data as AgentPlayerRow[];
const { data } = await api.get(`/agent/agents/${agentUserId}/players`);
agentPlayersMap.value[agentUserId] = (data.data ?? []) as ScopedPlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
agentPlayersMap.value[agentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
expandLoading.value[agentUserId] = false;
}
}
function getSubAgentPlayers(userId: string) {
return subAgentPlayersMap.value[userId] || [];
function getPlayers(agentUserId: string) {
return agentPlayersMap.value[agentUserId] ?? [];
}
function refreshExpandedAgentPlayers() {
for (const uid of expandedSet.value) {
void loadExpansionData(uid);
}
}
/* ─── Player CRUD ─── */
@@ -310,7 +504,7 @@ async function submitCreate() {
await api.post('/agent/players', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
loadPlayers();
loadAllPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
@@ -319,7 +513,8 @@ async function submitCreate() {
}
}
async function openEdit(row: AgentPlayerRow) {
async function openEdit(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
const { data } = await api.get(`/agent/players/${row.id}`);
editForm.value = editFormFromAgentDetail(data.data as AgentPlayerDetail);
@@ -347,13 +542,13 @@ async function submitEdit() {
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
editForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
loadPlayers();
loadAllPlayers();
return;
}
ElMessage.success(t('msg.saved'));
editVisible.value = false;
loadPlayers();
refreshSubAgentPlayers();
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -363,7 +558,8 @@ async function submitEdit() {
}
/* ─── Freeze ─── */
async function toggleFreeze(row: AgentPlayerRow) {
async function toggleFreeze(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
const freeze = row.status === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
@@ -376,8 +572,8 @@ async function toggleFreeze(row: AgentPlayerRow) {
try {
await api.put(`/agent/players/${row.id}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadPlayers();
refreshSubAgentPlayers();
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
@@ -385,7 +581,8 @@ async function toggleFreeze(row: AgentPlayerRow) {
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: AgentPlayerRow) {
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
transferType.value = type;
transferTarget.value = row;
transferContext.value = null;
@@ -434,7 +631,7 @@ async function submitTransfer() {
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
loadPlayers();
loadAllPlayers();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -470,8 +667,9 @@ async function submitCreateSub() {
await api.post('/agent/agents', payload);
ElMessage.success(t('msg.agent_created'));
createSubVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
loadProfile();
activeViewTab.value = agentLevelTabName(childAgentLevel.value);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
@@ -508,12 +706,12 @@ async function submitEditSub() {
editSubForm.value.managedPassword = updated.managedPassword ?? newPwd;
editSubForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editSubForm.value.managedPassword }));
loadSubAgents();
await reloadSubAgentTabs();
return;
}
ElMessage.success(t('msg.saved'));
editSubVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -557,7 +755,7 @@ async function submitCreditSub() {
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -581,7 +779,7 @@ async function toggleFreezeSub(row: AgentSubAgentRow) {
try {
await api.put(`/agent/agents/${row.userId}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadSubAgents();
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
@@ -589,12 +787,6 @@ async function toggleFreezeSub(row: AgentSubAgentRow) {
}
/* ─── Helpers ─── */
function refreshSubAgentPlayers() {
for (const uid of expandedSet.value) {
loadSubAgentPlayers(uid);
}
}
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
@@ -626,6 +818,11 @@ function statusTagType(s: string) {
<div class="admin-list-page agent-portal-mgr">
<!-- Credit strip -->
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent_portal.my_cashback_rate') }}</span>
<span class="credit-value c-green">{{ formatRatePercent(profile.cashbackRate) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
@@ -649,13 +846,43 @@ function statusTagType(s: string) {
{{ t('invite.menu_btn') }}
</el-button>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="keyword" :placeholder="t('agent_portal.search_player_ph')" clearable style="width: 180px" />
<el-tabs v-model="activeViewTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 玩家 -->
<el-tab-pane :label="playerTabLabel" name="players">
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="playerKeyword"
:placeholder="t('agent_portal.search_player_ph')"
clearable
style="width: 180px"
@keyup.enter="searchPlayers"
/>
</el-form-item>
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="playerFilterAgent"
:placeholder="t('user.filter.agent_ph')"
clearable
style="width: 200px"
>
<el-option
v-for="a in scopedAgentOptions"
:key="a.id"
:label="agentOptionLabel(a)"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="playerFilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="searchPlayers">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreate">
@@ -663,10 +890,19 @@ function statusTagType(s: string) {
</el-button>
</div>
<el-table v-loading="loadingPlayers" :data="filteredPlayers" stripe size="small" class="inner-table">
<AdminTableWrap>
<el-table v-loading="loadingPlayers" :data="allPlayers" stripe size="small" class="inner-table">
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.agent')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.parentAgentUsername ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
@@ -680,7 +916,7 @@ function statusTagType(s: string) {
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }">
<template v-if="row.wallet?.availableBalance != null">
<template v-if="canViewPlayer(row) && row.wallet?.availableBalance != null">
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
</el-tooltip>
@@ -688,91 +924,171 @@ function statusTagType(s: string) {
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center" fixed="right">
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-if="canOperatePlayer(row)"
:row="row"
:show-detail="false"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@ledger="openPlayerWalletLedger(row.id, row.username, row)"
@edit="openEdit(row)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
/>
<el-button
v-else-if="canViewPlayer(row)"
link
type="primary"
size="small"
@click="openPlayerWalletLedger(row.id, row.username, row)"
>
{{ t('user.action.ledger_short') }}
</el-button>
<span v-else></span>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="playerPage"
v-model:page-size="playerPageSize"
:total="playerTotal"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="onPlayerPageChange"
@size-change="onPlayerSizeChange"
/>
</div>
</el-tab-pane>
<!-- Tab: 下级代理 (仅一级代理可见) -->
<el-tab-pane v-if="canManageSubAgents" :label="subAgentsTabLabel" name="subAgents">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="subAgentKeyword" :placeholder="t('agent_portal.search_sub_agent_ph')" clearable style="width: 180px" />
<!-- Tab: 各级代理 -->
<el-tab-pane
v-for="agentLevel in visibleSubAgentTabLevels"
:key="agentLevel"
:label="agentLevelTabLabel(agentLevel)"
:name="agentLevelTabName(agentLevel)"
>
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="ensureSubAgentState(agentLevel).keyword"
:placeholder="t('agent_portal.search_sub_agent_ph')"
clearable
style="width: 180px"
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select
v-model="ensureSubAgentState(agentLevel).filterStatus"
:placeholder="t('common.all')"
clearable
style="width: 120px"
>
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreateSub">
<el-button
v-if="agentLevel === childAgentLevel"
type="primary"
size="small"
@click="openCreateSub"
>
{{ createSubBtnLabel }}
</el-button>
</div>
<AdminTableWrap>
<el-table
ref="subAgentTableRef"
v-loading="loadingAgents"
:data="filteredSubAgents"
:data="ensureSubAgentState(agentLevel).agents"
stripe
size="small"
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="onSubAgentExpand"
@expand-change="onExpandChange"
@row-click="onSubAgentRowClick"
>
<template #empty><div class="empty-hint">{{ noSubAgentsHint }}</div></template>
<template #empty>
<div class="empty-hint">
{{ agentLevel === childAgentLevel
? (childAgentLevel === 2 ? t('agent_portal.no_sub_agents') : t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }))
: t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }) }}
</div>
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') }}
</div>
<template v-else>
<div class="expand-section-title">{{ t('nav.players') }} ({{ getSubAgentPlayers(row.userId).length }})</div>
<p class="expand-readonly-hint">{{ subAgentPlayersReadonlyHint }}</p>
<el-table :data="getSubAgentPlayers(row.userId)" stripe size="small" class="inner-table nested-table">
<template #empty><div class="empty-hint">暂无数据</div></template>
<div v-else class="expand-panel-body">
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
<div class="expand-section-title">
{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}
</div>
<el-table
:data="getPlayers(row.userId)"
stripe
size="small"
class="inner-table nested-table"
>
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row: player }">
{{ formatRatePercent(player.cashbackRate) }}
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: p }">
<code v-if="p.inviteCode" class="invite-code-cell">{{ p.inviteCode }}</code>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row: p }">
<template v-if="p.wallet?.availableBalance != null">
<span>{{ formatAmount(p.wallet.availableBalance) }}</span>
<template #default="{ row: player }">
<template v-if="canViewPlayer(player) && player.wallet?.availableBalance != null">
<span>{{ formatAmount(player.wallet.availableBalance) }}</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row: p }">{{ formatTime(p.createdAt) }}</template>
<template #default="{ row: player }">{{ formatTime(player.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column v-if="agentLevel > childAgentLevel" :label="t('agent.col.parent_chain')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.parentChainLabel ?? row.parentUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">
@@ -787,23 +1103,41 @@ function statusTagType(s: string) {
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="240" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openEditSub(row)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeSub(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
v-if="isDirectChildAgent(row)"
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:show-detail="false"
@edit="openEditSub(row)"
@credit="openCreditSub(row)"
@freeze="toggleFreezeSub(row)"
/>
<span v-else class="readonly-actions-hint"></span>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="ensureSubAgentState(agentLevel).page"
v-model:page-size="ensureSubAgentState(agentLevel).pageSize"
:total="ensureSubAgentState(agentLevel).total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
@@ -837,8 +1171,15 @@ function statusTagType(s: string) {
<el-input-number v-model="createForm.initialDeposit" :min="initialDepositRange.min" :max="initialDepositRange.max" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.initial_deposit_hint') }}</div>
</el-form-item>
<el-form-item v-if="createForm.initialDeposit > 0" :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
<el-form-item
v-if="createForm.initialDeposit > 0"
:label="t('user.field.initial_deposit_kind')"
>
<InitialDepositRemarkField
v-model:kind="createForm.initialDepositRemarkKind"
v-model:custom="createForm.initialDepositRemarkCustom"
operator="agent"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -848,45 +1189,68 @@ function statusTagType(s: string) {
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" width="480px" destroy-on-close class="user-edit-dialog">
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" width="560px" destroy-on-close class="user-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editForm.id }}</span>
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.basic_info') }}</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
<div class="edit-form-section">
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
<el-input v-model="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.contact') }}</div>
<el-row :gutter="12" class="contact-row">
<el-col :span="12">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="edit-form-section edit-stats-panel">
<div class="section-title">{{ t('user.section.account_overview') }}</div>
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
@@ -954,7 +1318,7 @@ function statusTagType(s: string) {
</el-dialog>
<!-- Edit Sub-Agent -->
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close class="agent-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editSubForm.userId }}</span>
@@ -1113,6 +1477,7 @@ function statusTagType(s: string) {
.expand-panel { padding: 4px 16px 8px; }
.expand-loading { text-align: center; padding: 16px; color: #888; font-size: 13px; }
.expand-section-title { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 6px; }
.expand-section-title--spaced { margin-top: 14px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
@@ -1121,18 +1486,4 @@ function statusTagType(s: string) {
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
.create-alert { margin-bottom: 16px; }
.create-form { margin-top: 4px; }
/* ─── Edit dialog ─── */
.edit-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; font-size: 12px; color: #666; }
.password-mgmt-block {
margin: 8px 0 16px; padding: 12px 14px;
border-radius: 8px; border: 1px solid #1e1e1e;
background: rgba(255,255,255,.02);
}
.block-title { font-size: 12px; font-weight: 600; color: #888; margin-bottom: 10px; }
.password-plain { font-family: ui-monospace, monospace; color: #e0e0e0; }
.password-empty { color: #555; }
.block-hint { margin: -4px 0 10px; }
.edit-stats { margin-top: 8px; }
.compact-edit-form :deep(.el-form-item) { margin-bottom: 10px; }
</style>

View File

@@ -1,4 +1,6 @@
import type { InitialDepositRemarkKind } from '@thebet365/shared';
import { FormValidationError } from '../../i18n/form-validation';
import { buildInitialDepositRemark } from '../../utils/initial-depositRemark';
import { assertPlayerUsername } from '../user-form';
export interface AgentPlayerCreateForm {
@@ -8,7 +10,8 @@ export interface AgentPlayerCreateForm {
phone: string;
email: string;
initialDeposit: number;
remark: string;
initialDepositRemarkKind: InitialDepositRemarkKind | '';
initialDepositRemarkCustom: string;
}
export interface AgentPlayerRow {
@@ -17,6 +20,7 @@ export interface AgentPlayerRow {
status: string;
createdAt: string;
inviteCode?: string | null;
cashbackRate?: string;
wallet?: { availableBalance: string; frozenBalance?: string };
}
@@ -62,7 +66,8 @@ export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
phone: '',
email: '',
initialDeposit: 0,
remark: '',
initialDepositRemarkKind: '',
initialDepositRemarkCustom: '',
};
}
@@ -78,7 +83,12 @@ export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
remark: form.remark.trim() || undefined,
remark: buildInitialDepositRemark(
form.initialDeposit,
form.initialDepositRemarkKind,
form.initialDepositRemarkCustom,
'agent',
),
};
}

View File

@@ -1,11 +1,38 @@
import { FormValidationError } from '../../i18n/form-validation';
export interface AgentSubAgentDownlineAgentRow extends AgentSubAgentRow {
parentUsername: string;
}
export interface AgentSubAgentDownlinePlayerRow {
id: string;
username: string;
status: string;
createdAt: string;
inviteCode: string | null;
parentAgentUsername: string;
cashbackRate?: string;
wallet?: {
availableBalance: string;
frozenBalance: string;
};
}
export interface AgentSubAgentDownlineView {
agents: AgentSubAgentDownlineAgentRow[];
players: AgentSubAgentDownlinePlayerRow[];
}
export interface AgentSubAgentRow {
userId: string;
username: string;
userStatus: string;
status: string;
level: number;
parentAgentId?: string | null;
parentUsername?: string | null;
parentChainLabel?: string | null;
cashbackRate?: string;
creditLimit: string;
usedCredit: string;
availableCredit: string;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, watch, h } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import { ensureLeagueExpanded } from '../../utils/matchesListState';
@@ -165,9 +165,79 @@ function canPublishRow(row: unknown) {
function canCloseRow(row: unknown) {
return matchStatus(row) === 'PUBLISHED';
}
function canReopenRow(row: unknown) {
return matchStatus(row) === 'CLOSED';
}
function canSettleRow(row: unknown) {
return matchStatus(row) !== 'DRAFT';
}
function settleButtonLabel(row: unknown) {
return matchStatus(row) === 'SETTLED' ? t('common.resettle') : t('common.settle');
}
function kickoffPassed(row: unknown) {
return new Date(String(rowOf(row).startTime)) <= new Date();
}
async function promptReopenKickoff(): Promise<string | null> {
const kickoff = ref('');
try {
await ElMessageBox({
title: t('match.reopen_kickoff_title'),
message: () =>
h('div', { class: 'reopen-kickoff-prompt' }, [
h(
'p',
{
style: 'margin: 0 0 12px; font-size: 13px; color: var(--el-text-color-secondary)',
},
t('match.reopen_kickoff_hint'),
),
h(ElDatePicker, {
modelValue: kickoff.value,
'onUpdate:modelValue': (v: string) => {
kickoff.value = v;
},
type: 'datetime',
valueFormat: 'YYYY-MM-DDTHH:mm:ss',
style: 'width: 100%',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
beforeClose: (action, _instance, done) => {
if (action === 'confirm') {
if (!kickoff.value || new Date(kickoff.value) <= new Date()) {
ElMessage.warning(t('match.reopen_kickoff_invalid'));
return;
}
}
done();
},
});
return kickoff.value || null;
} catch {
return null;
}
}
async function reopenRow(row: unknown) {
const id = matchId(row);
let startTime: string | undefined;
if (kickoffPassed(row)) {
const picked = await promptReopenKickoff();
if (!picked) return;
startTime = picked;
}
try {
await api.post(`/admin/matches/${id}/reopen`, startTime ? { startTime } : {});
ElMessage.success(t('msg.reopened'));
notifyParent();
} catch (e) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
}
}
async function confirmDelete(row: unknown) {
const id = matchId(row);
@@ -271,13 +341,22 @@ defineExpose({ reload: load });
>
{{ t('common.close_betting') }}
</el-button>
<el-button
size="small"
type="success"
plain
:disabled="!canReopenRow(row)"
@click="reopenRow(row)"
>
{{ t('common.reopen_betting') }}
</el-button>
<el-button
size="small"
type="primary"
:disabled="!canSettleRow(row)"
@click="settle(matchId(row))"
>
{{ t('common.settle') }}
{{ settleButtonLabel(row) }}
</el-button>
</div>
<el-button

View File

@@ -1,4 +1,6 @@
import type { InitialDepositRemarkKind } from '@thebet365/shared';
import { FormValidationError } from '../i18n/form-validation';
import { buildInitialDepositRemark } from '../utils/initial-depositRemark';
import { decimalRateToPercent } from '../utils/rate-percent';
import { percentToDecimalRate } from '../utils/rate-percent';
@@ -21,7 +23,8 @@ export interface PlayerCreateForm {
phone: string;
email: string;
initialDeposit: number;
remark: string;
initialDepositRemarkKind: InitialDepositRemarkKind | '';
initialDepositRemarkCustom: string;
/** 创建为一级代理(非玩家) */
asTier1Agent: boolean;
creditLimit: number;
@@ -105,7 +108,8 @@ export function emptyPlayerCreateForm(): PlayerCreateForm {
phone: '',
email: '',
initialDeposit: 0,
remark: '',
initialDepositRemarkKind: '',
initialDepositRemarkCustom: '',
asTier1Agent: false,
creditLimit: 50000,
cashbackRate: 0,
@@ -192,6 +196,11 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
remark: form.remark.trim() || undefined,
remark: buildInitialDepositRemark(
form.initialDeposit,
form.initialDepositRemarkKind,
form.initialDepositRemarkCustom,
'admin',
),
};
}

View File

@@ -7,6 +7,10 @@ export default defineConfig({
resolve: {
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'],
alias: {
// shared 的 dist 为 CommonJSVite 无法按命名导出加载;直连源码
'@thebet365/shared': resolve(__dirname, '../../packages/shared/src/index.ts'),
},
dedupe: ['echarts', 'vue-echarts', 'vue'],
},
optimizeDeps: {

View File

@@ -556,6 +556,12 @@ class UpdatePlatformMatchDto {
awayTeamLogoUrl?: string;
}
class ReopenMatchDto {
@IsOptional()
@IsString()
startTime?: string;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
@@ -1643,6 +1649,14 @@ export class AdminController {
return jsonResponse(match);
}
@Post('matches/:id/reopen')
@RequirePermissions(P.matches)
async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) {
const startTime = dto.startTime ? new Date(dto.startTime) : undefined;
const match = await this.matches.reopenMatch(BigInt(id), startTime);
return jsonResponse(match);
}
@Post('matches/:id/cancel')
@RequirePermissions(P.matches)
async cancelMatch(@Param('id') id: string) {

View File

@@ -12,7 +12,8 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appForbidden } from '../../shared/common/app-error';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { validateInitialDepositRemark } from '@thebet365/shared';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { BetsService } from '../../domains/betting/bets.service';
@@ -164,7 +165,11 @@ export class AgentPortalController {
const profile = await this.agents.getProfile(agentId);
const maxLevel = await this.agents.getMaxAgentLevel();
return jsonResponse({
...profile,
level: profile.level,
creditLimit: profile.creditLimit.toString(),
usedCredit: profile.usedCredit.toString(),
availableCredit: profile.availableCredit.toString(),
cashbackRate: profile.cashbackRate.toString(),
maxAgentLevel: maxLevel,
canManageSubAgents: this.agents.canCreateSubAgent(level, maxLevel),
});
@@ -176,6 +181,25 @@ export class AgentPortalController {
return jsonResponse(players);
}
@Get('players/scoped')
async listScopedPlayers(
@CurrentUser('id') agentId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const result = await this.agents.listSubtreePlayersForPortal(agentId, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword,
status,
parentAgentId,
});
return jsonResponse(result);
}
@Post('players')
async createPlayer(@CurrentUser('id') agentId: bigint, @Body() dto: CreatePlayerDto) {
const user = await this.agents.createPlayer(agentId, {
@@ -188,12 +212,14 @@ export class AgentPortalController {
});
if (dto.initialDeposit != null && dto.initialDeposit > 0) {
const remarkResult = validateInitialDepositRemark(dto.initialDeposit, dto.remark, 'agent');
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
await this.agents.depositToPlayer(
agentId,
user.id,
dto.initialDeposit,
`agent-create-${user.id}-${Date.now()}`,
dto.remark ?? '开户初始余额',
remarkResult.remark,
);
}
@@ -245,6 +271,40 @@ export class AgentPortalController {
return jsonResponse(agents);
}
@Get('agents/level-counts')
async subtreeAgentLevelCounts(@CurrentUser('id') agentId: bigint) {
const counts = await this.agents.countSubtreeAgentsByLevel(agentId);
return jsonResponse(counts);
}
@Get('agents/options')
async subtreeAgentOptions(@CurrentUser('id') agentId: bigint) {
const options = await this.agents.listSubtreeAgentOptions(agentId);
return jsonResponse(options);
}
@Get('agents/by-level')
async subtreeAgentsByLevel(
@CurrentUser('id') agentId: bigint,
@Query('level') level: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
const lvl = parseInt(level, 10);
if (!Number.isFinite(lvl) || lvl < 1) {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
const result = await this.agents.listSubtreeAgentsAtLevel(agentId, lvl, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword,
status,
});
return jsonResponse(result);
}
@Post('agents')
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
const maxLevel = await this.agents.getMaxAgentLevel();
@@ -323,6 +383,19 @@ export class AgentPortalController {
return jsonResponse(detail);
}
@Get('agents/:id/downline')
async getSubAgentDownline(
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse({ agents: [], players: [] });
}
const downline = await this.agents.getDirectChildDownlineView(agentId, BigInt(subAgentId));
return jsonResponse(downline);
}
@Get('agents/:id/players')
async listSubAgentPlayers(
@CurrentUser('id') agentId: bigint,
@@ -332,8 +405,8 @@ export class AgentPortalController {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse([]);
}
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getDirectPlayers(BigInt(subAgentId));
await this.agents.assertDescendantAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getPortalAgentDirectPlayers(agentId, BigInt(subAgentId));
return jsonResponse(players);
}
@@ -494,16 +567,7 @@ export class AgentPortalController {
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 });
}
await this.agents.requirePlayerInPortalSubtree(agentId, BigInt(playerId));
}
const result = await this.wallet.listWalletTransactionsAdmin({
page: page ? parseInt(page, 10) : 1,

View File

@@ -7,7 +7,7 @@ import { AuthService } from '../identity/auth.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import { assertPlayerUsername } from '@thebet365/shared';
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
@@ -87,6 +87,157 @@ export class AgentsService {
return map;
}
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
if (agentUserIds.length === 0) return new Map();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: agentUserIds } },
select: { userId: true, cashbackRate: true },
});
return new Map(profiles.map((p) => [p.userId.toString(), p.cashbackRate.toString()]));
}
private async playerEffectiveCashbackRateMap(
players: Array<{ id: bigint; parentId: bigint | null }>,
parentCashbackMap: Map<string, string>,
): Promise<Map<string, string>> {
if (players.length === 0) return new Map();
const playerIds = players.map((p) => p.id);
const customRules = await this.prisma.cashbackRule.findMany({
where: {
targetType: 'USER',
targetId: { in: playerIds },
isActive: true,
marketType: null,
},
orderBy: { updatedAt: 'desc' },
});
const customMap = new Map<string, string>();
for (const rule of customRules) {
if (!rule.targetId) continue;
const id = rule.targetId.toString();
if (!customMap.has(id) && new Decimal(rule.rate).gt(0)) {
customMap.set(id, rule.rate.toString());
}
}
const result = new Map<string, string>();
for (const p of players) {
const key = p.id.toString();
const custom = customMap.get(key);
if (custom) {
result.set(key, custom);
continue;
}
if (p.parentId) {
result.set(key, parentCashbackMap.get(p.parentId.toString()) ?? '0');
} else {
result.set(key, '0');
}
}
return result;
}
/** 代理端:从当前登录代理向下构建上级链,不包含更上层代理 */
private async buildScopedAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
const rootKey = rootAgentId.toString();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
pending.add(rootAgentId);
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 &&
profile.parentAgentId.toString() !== rootKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
}
private async getAgentPortalScope(rootAgentId: bigint) {
const profile = await this.getProfile(rootAgentId);
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
subtreeIdSet,
};
}
private assertAgentInPortalSubtree(
scope: { subtreeIdSet: Set<string> },
agentId: bigint,
) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
}
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
}
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
if (!Number.isInteger(level) || level < 1) {
throw appBadRequest('AGENT_LEVEL_INVALID');
@@ -341,6 +492,7 @@ export class AgentsService {
operatorId,
remark ?? '管理员上分',
requestId,
'ADMIN_DEPOSIT',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
@@ -366,6 +518,7 @@ export class AgentsService {
operatorId,
remark ?? '管理员下分',
requestId,
'ADMIN_WITHDRAW',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
@@ -519,7 +672,7 @@ export class AgentsService {
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
await this.recalculateUsedCredit(agentId);
return { success: true };
@@ -534,7 +687,14 @@ export class AgentsService {
) {
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(playerId, amount, agentId, remark ?? '代理下分', requestId);
await this.wallet.withdraw(
playerId,
amount,
agentId,
remark ?? '代理下分',
requestId,
'AGENT_WITHDRAW',
);
await this.recalculateUsedCredit(agentId);
return { success: true };
@@ -1515,6 +1675,8 @@ export class AgentsService {
const initial = data.initialDeposit ?? 0;
if (initial > 0) {
const remarkResult = validateInitialDepositRemark(initial, data.depositRemark, 'admin');
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.assertPlayerParentCreditForDeposit(user.id, initial);
@@ -1522,8 +1684,9 @@ export class AgentsService {
user.id,
initial,
operatorId,
data.depositRemark ?? '开户初始余额',
remarkResult.remark,
requestId,
'ADMIN_DEPOSIT',
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
@@ -1533,6 +1696,34 @@ export class AgentsService {
return user;
}
async getPortalAgentDirectPlayers(rootAgentId: bigint, targetAgentId: bigint) {
await this.assertDescendantAgent(rootAgentId, targetAgentId);
const players = await this.getDirectPlayers(targetAgentId);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
select: {
cashbackRate: true,
user: { select: { username: true } },
},
});
const rootKey = rootAgentId.toString();
const targetKey = targetAgentId.toString();
const parentAgentUsername = profile?.user.username ?? '—';
const parentCashbackMap = await this.agentCashbackRateMap([targetAgentId]);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
parentCashbackMap,
);
return players.map((p) => ({
...p,
parentAgentId: targetKey,
parentAgentUsername,
cashbackRate: playerCashbackMap.get(p.id) ?? '0',
inChain: true,
isDirect: targetKey === rootKey,
}));
}
async getDirectPlayers(agentId: bigint) {
const rows = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
@@ -1592,6 +1783,8 @@ export class AgentsService {
userStatus: p.user.status,
status: p.status,
level: p.level,
parentAgentId: p.parentAgentId?.toString() ?? null,
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
@@ -1601,6 +1794,403 @@ export class AgentsService {
});
}
/** Read-only downline under a direct sub-agent (all descendant agents + subtree players). */
async getDirectChildDownlineView(parentAgentId: bigint, subAgentId: bigint) {
await this.assertDirectChildAgent(parentAgentId, subAgentId);
const subtreeIds = await this.getSubtreeAgentIds(subAgentId);
const descendantAgentIds = subtreeIds.filter((id) => id !== subAgentId);
let agents: Array<{
userId: string;
username: string;
userStatus: string;
status: string;
level: number;
parentUsername: string;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
createdAt: Date;
}> = [];
if (descendantAgentIds.length > 0) {
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: descendantAgentIds } },
include: { user: true },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
});
const parentAgentIds = [
...new Set(
profiles
.map((p) => p.parentAgentId)
.filter((id): id is bigint => id != null),
),
];
const parentUsers =
parentAgentIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: parentAgentIds } },
select: { id: true, username: true },
})
: [];
const parentNameMap = new Map(
parentUsers.map((u) => [u.id.toString(), u.username]),
);
const playerCounts = await this.prisma.user.groupBy({
by: ['parentId'],
where: {
userType: 'PLAYER',
parentId: { in: descendantAgentIds },
deletedAt: null,
},
_count: { _all: true },
});
const countMap = new Map(
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
agents = profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
return {
userId: p.userId.toString(),
username: p.user.username,
userStatus: p.user.status,
status: p.status,
level: p.level,
parentUsername: p.parentAgentId
? parentNameMap.get(p.parentAgentId.toString()) ?? '—'
: '—',
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
createdAt: p.createdAt,
};
});
}
const playerRows =
subtreeIds.length > 0
? await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
parentId: { in: subtreeIds },
},
include: {
wallet: true,
usedInvite: { select: { code: true } },
parent: { select: { username: true } },
},
orderBy: { createdAt: 'desc' },
})
: [];
const parentAgentIdsForPlayers = [
...new Set(
playerRows
.map((u) => u.parentId)
.filter((id): id is bigint => id != null),
),
];
const parentCashbackMap = await this.agentCashbackRateMap(parentAgentIdsForPlayers);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
playerRows.map((u) => ({ id: u.id, parentId: u.parentId })),
parentCashbackMap,
);
const players = playerRows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
createdAt: u.createdAt,
inviteCode: u.usedInvite?.code ?? null,
parentAgentUsername: u.parent?.username ?? '—',
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
wallet: u.wallet
? {
availableBalance: u.wallet.availableBalance.toString(),
frozenBalance: u.wallet.frozenBalance.toString(),
}
: undefined,
}));
return {
agents: agents.map((a) => ({
...a,
createdAt: a.createdAt.toISOString(),
})),
players,
};
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
}
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
if (scope.descendantIds.length === 0) return {} as Record<number, number>;
const groups = await this.prisma.agentProfile.groupBy({
by: ['level'],
where: {
userId: { in: scope.descendantIds },
level: { gt: scope.rootLevel },
user: { deletedAt: null },
},
_count: { _all: true },
});
const out: Record<number, number> = {};
for (const g of groups) {
if (g.level > scope.rootLevel) {
out[g.level] = g._count._all;
}
}
return out;
}
async listSubtreeAgentsAtLevel(
rootAgentId: bigint,
level: number,
params?: { page?: number; pageSize?: number; keyword?: string; status?: string },
) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
const skip = (page - 1) * pageSize;
const scope = await this.getAgentPortalScope(rootAgentId);
if (level <= scope.rootLevel || scope.descendantIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where: Prisma.AgentProfileWhereInput = {
userId: { in: scope.descendantIds },
level,
};
const kw = params?.keyword?.trim();
const status = params?.status?.trim();
const userWhere: Prisma.UserWhereInput = { deletedAt: null };
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
userWhere.status = status;
}
if (kw) {
userWhere.username = { contains: kw, mode: 'insensitive' };
}
where.user = userWhere;
const [profiles, total] = await Promise.all([
this.prisma.agentProfile.findMany({
where,
include: { user: true },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.agentProfile.count({ where }),
]);
const agentIds = profiles.map((p) => p.userId);
const playerCounts =
agentIds.length > 0
? await this.prisma.user.groupBy({
by: ['parentId'],
where: {
userType: 'PLAYER',
parentId: { in: agentIds },
deletedAt: null,
},
_count: { _all: true },
})
: [];
const countMap = new Map(
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
const parentAgentIds = [
...new Set(
profiles
.map((p) => p.parentAgentId)
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString())),
),
];
const parentUsers =
parentAgentIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: parentAgentIds } },
select: { id: true, username: true },
})
: [];
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
const parentChainMap = await this.buildScopedAncestorChainMap(
profiles.map((p) => p.parentAgentId),
rootAgentId,
);
const items = profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
const parentChain = p.parentAgentId
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
: [];
const parentUsername =
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
: null;
return {
userId: p.userId.toString(),
username: p.user.username,
userStatus: p.user.status,
status: p.status,
level: p.level,
parentAgentId: p.parentAgentId?.toString() ?? null,
parentUsername,
parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
createdAt: p.createdAt.toISOString(),
};
});
return { items, total, page, pageSize };
}
async listSubtreePlayersForPortal(
rootAgentId: bigint,
params?: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
parentAgentId?: string;
},
) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
const skip = (page - 1) * pageSize;
const scope = await this.getAgentPortalScope(rootAgentId);
const where: Prisma.UserWhereInput = {
userType: 'PLAYER',
deletedAt: null,
parentId: { in: scope.subtreeIds },
};
if (params?.parentAgentId) {
this.assertAgentInPortalSubtree(scope, BigInt(params.parentAgentId));
where.parentId = BigInt(params.parentAgentId);
}
const kw = params?.keyword?.trim();
if (kw) {
where.username = { contains: kw, mode: 'insensitive' };
}
const status = params?.status?.trim();
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
where.status = status;
}
const [rows, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: {
wallet: true,
usedInvite: { select: { code: true } },
parent: { select: { id: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.user.count({ where }),
]);
const parentIds = [
...new Set(
rows
.map((u) => u.parentId)
.filter((id): id is bigint => id != null),
),
];
const parentCashbackMap = await this.agentCashbackRateMap(parentIds);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
rows.map((u) => ({ id: u.id, parentId: u.parentId })),
parentCashbackMap,
);
const rootKey = rootAgentId.toString();
const items = rows.map((u) => {
const parentId = u.parentId!.toString();
return {
id: u.id.toString(),
username: u.username,
status: u.status,
createdAt: u.createdAt.toISOString(),
inviteCode: u.usedInvite?.code ?? null,
parentAgentId: parentId,
parentAgentUsername: u.parent?.username ?? '—',
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
inChain: true,
isDirect: parentId === rootKey,
wallet: u.wallet
? {
availableBalance: u.wallet.availableBalance.toString(),
frozenBalance: u.wallet.frozenBalance.toString(),
}
: undefined,
};
});
return { items, total, page, pageSize };
}
async listSubtreeAgentOptions(rootAgentId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const profiles = await this.prisma.agentProfile.findMany({
where: {
userId: { in: scope.subtreeIds },
OR: [{ userId: rootAgentId }, { level: { gt: scope.rootLevel } }],
},
include: { user: { select: { username: true } } },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
});
const parentIds = profiles
.map((p) => p.parentAgentId)
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString()));
const parentUsers =
parentIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: [...new Set(parentIds)] } },
select: { id: true, username: true },
})
: [];
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
return profiles.map((p) => ({
id: p.userId.toString(),
username: p.user.username,
level: p.level,
parentUsername:
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
: null,
}));
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },

View File

@@ -1101,6 +1101,29 @@ export class MatchesService {
});
}
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
if (match.status !== 'CLOSED') throw appBadRequest('MATCH_NOT_REOPENABLE');
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
const effectiveStart = startTime ?? match.startTime;
if (!isPreMatchKickoff(effectiveStart)) {
throw appBadRequest('MATCH_REOPEN_KICKOFF_REQUIRED');
}
return this.prisma.match.update({
where: { id: matchId },
data: {
status: 'PUBLISHED',
closeTime: null,
...(startTime ? { startTime } : {}),
},
});
}
async cancelMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },

View File

@@ -432,6 +432,7 @@ export class DepositService {
reviewerId: operatorId,
reviewedAt: new Date(),
rejectReason: reason,
remark: reason,
},
});

View File

@@ -83,6 +83,7 @@ export class WalletService {
operatorId: bigint,
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
@@ -106,7 +107,7 @@ export class WalletService {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
transactionType,
amount: amt.neg(),
balanceBefore,
balanceAfter,
@@ -260,26 +261,178 @@ export class WalletService {
return this.prisma.$transaction(run);
}
private static readonly DEPOSIT_TX_TYPES = [
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'MANUAL_ADJUST',
'PLAYER_DEPOSIT',
] as const;
private static readonly WITHDRAW_TX_TYPES = [
'MANUAL_WITHDRAW',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'WITHDRAW',
] as const;
private static readonly SYSTEM_REMARKS = new Set([
'管理员上分',
'管理员下分',
'代理上分',
'代理下分',
'开户初始余额',
'Resettlement adjustment',
]);
private resolveDisplayType(
transactionType: string,
operatorType?: string | null,
): string {
const type = transactionType.toUpperCase();
if (type === 'INITIAL_DEPOSIT') {
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return 'ADMIN_DEPOSIT';
}
if (
[
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'PLAYER_DEPOSIT',
].includes(type)
) {
return type;
}
if (type === 'MANUAL_DEPOSIT') {
if (operatorType === 'ADMIN') return 'ADMIN_DEPOSIT';
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return type;
}
if (type === 'MANUAL_WITHDRAW') {
if (operatorType === 'ADMIN') return 'ADMIN_WITHDRAW';
if (operatorType === 'AGENT') return 'AGENT_WITHDRAW';
return type;
}
return type;
}
private isCustomRemark(remark: string | null | undefined): boolean {
const r = remark?.trim();
if (!r) return false;
if (WalletService.SYSTEM_REMARKS.has(r)) return false;
if (r.startsWith('Cashback batch ')) return false;
if (r.startsWith('Deposit order ')) return false;
return true;
}
private buildPlayerTxSummary(
tx: {
transactionType: string;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
},
depositMethodName?: string | null,
): string | null {
const type = tx.transactionType.toUpperCase();
if (tx.referenceType === 'BET' && tx.referenceId) {
return tx.referenceId;
}
if (type === 'CASHBACK' || type === 'CASHBACK_DEPOSIT') {
return tx.referenceId ?? null;
}
if (type === 'PLAYER_DEPOSIT') {
const parts = [depositMethodName?.trim(), tx.referenceId?.trim()].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}
if (this.isCustomRemark(tx.remark)) return tx.remark!.trim();
return null;
}
private resolveSummaryKind(remark: string | null | undefined): 'opening_bonus' | null {
const r = remark?.trim();
if (r === '开户初始余额') return 'opening_bonus';
return null;
}
private async enrichPlayerTransactions(
rows: Array<{
id: bigint;
transactionId: string;
transactionType: string;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
frozenBefore: Decimal;
frozenAfter: Decimal;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
operatorId: bigint | null;
createdAt: Date;
}>,
) {
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, userType: true },
})
: [];
const operatorTypeById = new Map(operators.map((o) => [o.id.toString(), o.userType]));
const depositMethodByRowId = await this.resolveDepositMethodsForRows(rows);
return rows.map((row) => {
const operatorType = row.operatorId
? operatorTypeById.get(row.operatorId.toString())
: null;
const displayType = this.resolveDisplayType(row.transactionType, operatorType);
const depositMethod = depositMethodByRowId.get(row.id.toString());
const summary = this.buildPlayerTxSummary(
row,
depositMethod?.depositMethodName,
);
const summaryKind = summary ? null : this.resolveSummaryKind(row.remark);
return {
transactionId: row.transactionId,
transactionType: row.transactionType,
displayType,
summaryKind,
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,
remark: row.remark,
summary,
createdAt: row.createdAt.toISOString(),
betNo: row.referenceType === 'BET' ? row.referenceId : null,
cashbackBatchNo:
row.transactionType === 'CASHBACK' || row.transactionType === 'CASHBACK_DEPOSIT'
? row.referenceId
: null,
};
});
}
async getTransactionDetail(userId: bigint, transactionId: string) {
const tx = await this.prisma.walletTransaction.findFirst({
where: { userId, transactionId },
});
if (!tx) return null;
return {
transactionId: tx.transactionId,
transactionType: tx.transactionType,
amount: tx.amount.toString(),
balanceBefore: tx.balanceBefore.toString(),
balanceAfter: tx.balanceAfter.toString(),
frozenBefore: tx.frozenBefore.toString(),
frozenAfter: tx.frozenAfter.toString(),
referenceType: tx.referenceType,
referenceId: tx.referenceId,
remark: tx.remark,
createdAt: tx.createdAt.toISOString(),
betNo: tx.referenceType === 'BET' ? tx.referenceId : null,
};
const [enriched] = await this.enrichPlayerTransactions([tx]);
return enriched;
}
async getTransactions(userId: bigint, page = 1, pageSize = 20, typeFilter?: string) {
@@ -287,9 +440,9 @@ export class WalletService {
let typeWhere: Record<string, unknown> = {};
if (typeFilter === 'deposit') {
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
typeWhere = { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
} else if (typeFilter === 'withdraw') {
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
typeWhere = { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
} else if (typeFilter === 'bet') {
typeWhere = { transactionType: { in: ['BET_FREEZE', 'BET_DEDUCT', 'BET_SETTLE_WIN', 'BET_SETTLE_LOSE', 'BET_SETTLE_PUSH', 'BET_WIN', 'BET_REFUND', 'BET_VOID', 'BET_VOID_REFUND', 'RESETTLE_REVERSE'] } };
} else if (typeFilter === 'cashback') {
@@ -306,16 +459,21 @@ export class WalletService {
}),
this.prisma.walletTransaction.count({ where }),
]);
return { items, total, page, pageSize };
return {
items: await this.enrichPlayerTransactions(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', 'PLAYER_DEPOSIT'] } };
return { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
}
if (cat === 'withdraw') {
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
return { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
}
if (cat === 'bet') {
return {
@@ -343,6 +501,9 @@ export class WalletService {
private static readonly DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
@@ -644,7 +805,15 @@ export class WalletService {
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const transferTypes = ['MANUAL_DEPOSIT', 'MANUAL_WITHDRAW'];
const transferTypes = [
'MANUAL_DEPOSIT',
'MANUAL_WITHDRAW',
'ADMIN_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_DEPOSIT',
'AGENT_WITHDRAW',
'INITIAL_DEPOSIT',
];
const where: Prisma.WalletTransactionWhereInput = {
transactionType: params.transactionType?.trim()
? params.transactionType.trim()

View File

@@ -1,252 +1,168 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import SlideVerify, { type SlideVerifyInstance } from 'vue3-slide-verify';
import 'vue3-slide-verify/dist/style.css';
const { t } = useI18n();
const slideRef = ref<SlideVerifyInstance>();
const input = ref('');
const code = ref('');
const canvasRef = ref<HTMLCanvasElement | null>(null);
const honeypot = ref('');
const validated = ref(false);
const showPopup = ref(false);
const errorMsg = ref('');
function openPopup() {
if (validated.value) return;
showPopup.value = true;
errorMsg.value = '';
// Refresh captcha after popup renders
nextTick(() => {
slideRef.value?.refresh();
});
}
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function closePopup() {
showPopup.value = false;
}
function onSuccess() {
validated.value = true;
errorMsg.value = '';
showPopup.value = false;
}
function onFail() {
validated.value = false;
}
function onAgain() {
validated.value = false;
slideRef.value?.refresh();
}
function validate(): boolean {
if (!validated.value) {
errorMsg.value = t('auth.captcha_wrong');
function generateCode() {
let result = '';
for (let i = 0; i < 4; i++) {
result += CHARS[Math.floor(Math.random() * CHARS.length)];
}
code.value = result;
}
function drawCaptcha() {
const canvas = canvasRef.value;
if (!canvas) return;
const w = 108, h = 44;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#2a2210';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 28; i++) {
ctx.fillStyle = `rgba(212, 175, 55, ${0.1 + Math.random() * 0.2})`;
ctx.beginPath();
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
ctx.fill();
}
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(212, 175, 55, ${0.15 + Math.random() * 0.25})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * w, Math.random() * h);
ctx.lineTo(Math.random() * w, Math.random() * h);
ctx.stroke();
}
const charWidth = w / (code.value.length + 1);
for (let i = 0; i < code.value.length; i++) {
ctx.save();
const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 4;
const y = h / 2 + (Math.random() - 0.5) * 6;
ctx.translate(x, y);
ctx.rotate((Math.random() - 0.5) * 0.4);
ctx.font = `bold ${18 + Math.random() * 6}px 'Courier New', monospace`;
ctx.fillStyle = `hsl(${40 + Math.random() * 20}, ${80 + Math.random() * 20}%, ${65 + Math.random() * 20}%)`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(code.value[i], 0, 0);
ctx.restore();
}
return validated.value;
}
function refresh() {
generateCode();
input.value = '';
validated.value = false;
errorMsg.value = '';
drawCaptcha();
}
function validate(): boolean {
if (honeypot.value) { refresh(); return false; }
if (!input.value.trim()) {
errorMsg.value = t('auth.captcha_wrong');
return false;
}
if (input.value.trim().toUpperCase() !== code.value.toUpperCase()) {
errorMsg.value = t('auth.captcha_wrong');
refresh();
return false;
}
validated.value = true;
errorMsg.value = '';
return true;
}
onMounted(refresh);
defineExpose({ validate, refresh });
</script>
<template>
<div class="captcha-trigger-wrapper">
<!-- Trigger row -->
<div class="captcha-trigger" :class="{ verified: validated }" @click="openPopup">
<span v-if="validated" class="captcha-success-icon"></span>
<span class="captcha-trigger-text">
{{ validated ? t('auth.verified') : t('auth.click_to_verify') }}
</span>
</div>
<p v-if="errorMsg && !validated" class="slide-error">{{ errorMsg }}</p>
<!-- Popup overlay -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showPopup" class="captcha-overlay" @click.self="closePopup">
<div class="captcha-popup">
<div class="captcha-popup-header">
<span>{{ t('auth.slide_to_verify') }}</span>
<button class="captcha-popup-close" @click="closePopup"></button>
</div>
<div class="captcha-popup-body">
<SlideVerify
ref="slideRef"
:w="300"
:h="150"
:l="42"
:r="9"
:accuracy="3"
:slider-text="t('auth.slide_to_verify')"
@success="onSuccess"
@fail="onFail"
@again="onAgain"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
<div class="captcha-row">
<input v-model="honeypot" type="text" name="website" tabindex="-1"
autocomplete="off" class="hp-field" aria-hidden="true" />
<input v-model="input" type="text" maxlength="4"
class="captcha-input" :placeholder="t('auth.captcha_placeholder')" autocomplete="off" />
<canvas ref="canvasRef" class="captcha-canvas"
:title="t('auth.captcha_refresh')" role="button" tabindex="0"
@click="refresh" @keydown.enter="refresh" />
</div>
<p v-if="errorMsg" class="captcha-error">{{ errorMsg }}</p>
</template>
<style scoped>
.captcha-trigger-wrapper {
width: 100%;
}
.captcha-trigger {
.captcha-row {
display: flex;
align-items: center;
gap: 8px;
gap: 0;
align-items: stretch;
height: 44px;
padding: 0 16px;
border-radius: 8px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: border-color 0.2s;
user-select: none;
}
.captcha-trigger:active {
border-color: var(--primary, #c8a84e);
}
.captcha-trigger.verified {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.08);
cursor: default;
}
.captcha-success-icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.captcha-trigger-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.captcha-trigger.verified .captcha-trigger-text {
color: #22c55e;
}
/* Popup */
.captcha-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.captcha-popup {
background: #1e1e1e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 340px;
width: calc(100vw - 40px);
}
.captcha-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.captcha-popup-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 16px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.captcha-popup-close:active {
color: rgba(255, 255, 255, 0.8);
}
.captcha-popup-body {
padding: 16px;
display: flex;
justify-content: center;
}
.captcha-popup-body :deep(.slide-verify) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-slider) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-info) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.6);
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__text) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__handler) {
background-color: var(--primary, #c8a84e);
}
.captcha-popup-body :deep(.slide-verify-slider__bg-fill) {
background-color: rgba(200, 168, 78, 0.25);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--success) {
background-color: rgba(34, 197, 94, 0.2);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--fail) {
background-color: rgba(239, 68, 68, 0.2);
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
.hp-field {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.slide-error {
.captcha-input {
flex: 1;
min-width: 0;
padding: 0 14px;
border: 1px solid var(--border);
border-right: none;
border-radius: 8px 0 0 8px;
background: #1a1a1a;
color: #fff;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
outline: none;
text-align: center;
}
.captcha-input::placeholder {
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
}
.captcha-input:focus {
border-color: var(--primary-light);
}
.captcha-canvas {
flex-shrink: 0;
width: 108px;
height: 44px;
cursor: pointer;
display: block;
border-radius: 0 8px 8px 0;
border: 1px solid var(--border);
border-left: none;
}
.captcha-error {
color: var(--danger);
font-size: 12px;
font-weight: 600;

View File

@@ -50,25 +50,25 @@ const stats = computed(() => {
<div class="wallet-stats-panel">
<div class="stats-row">
<div class="stat-item">
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
</div>
<div class="stat-divider" />
</div>
<div class="stats-divider" />
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
{{ formatMoneyCompact(stats.net, locale) }}
</span>
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
</div>
</div>
</div>
@@ -79,9 +79,8 @@ const stats = computed(() => {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 12px;
padding: 12px;
margin-bottom: 12px;
backdrop-filter: blur(10px);
}
.stats-row {
@@ -93,19 +92,22 @@ const stats = computed(() => {
flex: 1;
text-align: center;
min-width: 0;
padding: 4px 8px;
}
.stat-divider {
width: 1px;
height: 32px;
background: var(--border);
flex-shrink: 0;
.stat-label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.03em;
margin-bottom: 2px;
}
.stat-val {
display: block;
font-size: 15px;
font-weight: 900;
font-size: 14px;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
@@ -116,12 +118,9 @@ const stats = computed(() => {
.stat-val.expense { color: #e05050; }
.stat-val.cashback { color: #f0b90b; }
.stat-label {
display: block;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
margin-top: 3px;
.stats-divider {
height: 1px;
margin: 6px 8px;
background: linear-gradient(90deg, transparent, var(--border), transparent);
}
</style>

View File

@@ -44,11 +44,25 @@ export function useAppLocale() {
applyLocale(code);
}
/** 将当前语言同步到后端,仅在有 token 时执行,不修改本地 locale */
async function syncLocaleToBackend() {
if (!auth.token) return;
try {
await api.post('/player/language', { locale: locale.value });
if (auth.user) {
auth.user = { ...auth.user, locale: locale.value };
localStorage.setItem('user', JSON.stringify(auth.user));
}
} catch {
/* ignore */
}
}
function initFromUser(userLocale?: string | null) {
if (userLocale && (SUPPORTED_LOCALES as readonly string[]).includes(userLocale)) {
applyLocale(userLocale);
}
}
return { locales: APP_LOCALES, setLocale, applyLocale, initFromUser };
return { locales: APP_LOCALES, setLocale, applyLocale, initFromUser, syncLocaleToBackend };
}

View File

@@ -16,8 +16,10 @@ export function usePullToRefresh(options: PullToRefreshOptions) {
const progress = computed(() => Math.min(pullDistance.value / maxPull, 1));
let scrollEl: HTMLElement | null = null;
let startX = 0;
let startY = 0;
let pulling = false;
let locked = false;
function findScrollEl(): HTMLElement | null {
return document.querySelector('.layout > .main') as HTMLElement | null;
@@ -41,19 +43,38 @@ export function usePullToRefresh(options: PullToRefreshOptions) {
if (!scrollEl || refreshing.value) return;
if (scrollEl.scrollTop > 4) return;
if (isInsideScrollableChild(e.target)) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
pulling = true;
locked = false;
}
function handleTouchMove(e: TouchEvent) {
if (!pulling || refreshing.value) return;
const delta = e.touches[0].clientY - startY;
if (delta <= 0) {
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
if (!locked) {
if (Math.abs(dx) > Math.abs(dy) * 1.5) {
pulling = false;
pullDistance.value = 0;
spinning.value = false;
return;
}
if (dy > 0) {
locked = true;
}
}
if (!locked) return;
if (dy <= 0) {
pullDistance.value = 0;
spinning.value = false;
return;
}
const damped = Math.min(delta * 0.7, maxPull);
e.preventDefault();
const damped = Math.min(dy * 0.7, maxPull);
pullDistance.value = damped;
spinning.value = damped >= threshold * 0.5;
}
@@ -61,6 +82,7 @@ export function usePullToRefresh(options: PullToRefreshOptions) {
function handleTouchEnd() {
if (!pulling) return;
pulling = false;
locked = false;
if (pullDistance.value >= threshold && !refreshing.value) {
refreshing.value = true;
spinning.value = true;

View File

@@ -69,10 +69,10 @@ watch(
() => auth.token,
(token) => {
// 首页数据(公告、热门赛事)对所有人公开,始终加载
void loadPlayerHome();
void loadPlayerHome(true);
// 个人资料仅登录用户需要
if (token) {
void loadProfile();
void loadProfile(true);
}
},
{ immediate: true },

View File

@@ -17,6 +17,8 @@ const i18n = createI18n({
refreshing: '刷新中…',
loading_more: '加载更多…',
no_more: '没有更多了',
load_failed: '加载失败',
retry: '重试',
},
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
home: {
@@ -76,9 +78,9 @@ const i18n = createI18n({
password: '密码',
invite_code: '邀请码',
optional: '选填',
captcha_placeholder: 'Captcha',
captcha_placeholder: '验证码',
captcha_refresh: '点击换一张',
captcha_wrong: '请完成滑块验证',
captcha_wrong: '验证码错误',
slide_to_verify: '向右滑动完成验证',
click_to_verify: '点击验证',
verified: '验证成功',
@@ -110,7 +112,12 @@ const i18n = createI18n({
available: '可用',
no_records: '暂无账单记录',
tx_deposit: '充值',
tx_admin_deposit: '管理员上分',
tx_agent_deposit: '代理上分',
tx_player_deposit: '自助充值',
tx_withdraw: '人工提款',
tx_admin_withdraw: '管理员下分',
tx_agent_withdraw: '代理下分',
tx_adjust: '人工调整',
tx_bet_freeze: '投注冻结',
tx_bet_deduct: '投注扣款',
@@ -121,6 +128,8 @@ const i18n = createI18n({
tx_bet_void: '投注撤销',
tx_cashback: '返水入账',
tx_resettle: '重新结算',
summary_bet: '注单 {betNo}',
summary_opening_bonus: '开户赠金',
stats_income: '收入',
stats_expense: '支出',
stats_net: '净额',
@@ -179,7 +188,7 @@ const i18n = createI18n({
submit_failed: '提交失败,请重试',
file_must_be_image: '请上传图片文件',
file_too_large: '文件不能超过 10MB',
status_pending: '审核中',
status_pending: '充值中',
status_approved: '已通过',
status_rejected: '已拒绝',
no_orders: '暂无充值记录',
@@ -391,6 +400,8 @@ const i18n = createI18n({
refreshing: 'Refreshing…',
loading_more: 'Loading more…',
no_more: 'No more',
load_failed: 'Failed to load',
retry: 'Retry',
},
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
home: {
@@ -450,9 +461,9 @@ const i18n = createI18n({
password: 'Password',
invite_code: 'Invitation Code',
optional: 'Optional',
captcha_placeholder: 'Captcha',
captcha_placeholder: 'Code',
captcha_refresh: 'Click to refresh',
captcha_wrong: 'Please complete the slider verification',
captcha_wrong: 'Incorrect captcha code',
slide_to_verify: 'Slide to verify',
click_to_verify: 'Click to verify',
verified: 'Verified',
@@ -484,7 +495,12 @@ const i18n = createI18n({
available: 'Available',
no_records: 'No records',
tx_deposit: 'Deposit',
tx_admin_deposit: 'Admin top-up',
tx_agent_deposit: 'Agent top-up',
tx_player_deposit: 'Self deposit',
tx_withdraw: 'Withdrawal',
tx_admin_withdraw: 'Admin withdraw',
tx_agent_withdraw: 'Agent withdraw',
tx_adjust: 'Manual Adjust',
tx_bet_freeze: 'Bet Frozen',
tx_bet_deduct: 'Bet Deducted',
@@ -495,6 +511,8 @@ const i18n = createI18n({
tx_bet_void: 'Bet Voided',
tx_cashback: 'Cashback credit',
tx_resettle: 'Resettlement',
summary_bet: 'Bet {betNo}',
summary_opening_bonus: 'Opening bonus',
stats_income: 'Income',
stats_expense: 'Expense',
stats_net: 'Net',
@@ -553,7 +571,7 @@ const i18n = createI18n({
submit_failed: 'Submit failed, please retry',
file_must_be_image: 'Please upload an image file',
file_too_large: 'File exceeds 10MB',
status_pending: 'Pending',
status_pending: 'Processing',
status_approved: 'Approved',
status_rejected: 'Rejected',
no_orders: 'No recharge records',
@@ -765,6 +783,8 @@ const i18n = createI18n({
refreshing: 'Menyegarkan…',
loading_more: 'Memuat lagi…',
no_more: 'Tiada lagi',
load_failed: 'Gagal dimuat',
retry: 'Cuba lagi',
},
nav: {
home: 'Laman Utama',
@@ -830,9 +850,9 @@ const i18n = createI18n({
password: 'Kata Laluan',
invite_code: 'Kod Jemputan',
optional: 'Pilihan',
captcha_placeholder: 'Captcha',
captcha_placeholder: 'Kod',
captcha_refresh: 'Klik untuk muat semula',
captcha_wrong: 'Sila lengkapkan pengesahan gelongsor',
captcha_wrong: 'Kod captcha salah',
slide_to_verify: 'Gelongsor untuk mengesahkan',
click_to_verify: 'Klik untuk mengesahkan',
verified: 'Disahkan',
@@ -864,7 +884,12 @@ const i18n = createI18n({
available: 'Tersedia',
no_records: 'Tiada rekod',
tx_deposit: 'Deposit',
tx_admin_deposit: 'Tambah baki admin',
tx_agent_deposit: 'Tambah baki ejen',
tx_player_deposit: 'Deposit sendiri',
tx_withdraw: 'Pengeluaran',
tx_admin_withdraw: 'Pengeluaran admin',
tx_agent_withdraw: 'Pengeluaran ejen',
tx_adjust: 'Pelarasan Manual',
tx_bet_freeze: 'Pertaruhan Ditahan',
tx_bet_deduct: 'Pertaruhan Ditolak',
@@ -875,6 +900,8 @@ const i18n = createI18n({
tx_bet_void: 'Pertaruhan Dibatalkan',
tx_cashback: 'Kredit rebat',
tx_resettle: 'Penyelesaian Semula',
summary_bet: 'Pertaruhan {betNo}',
summary_opening_bonus: 'Bonus pembukaan',
stats_income: 'Pendapatan',
stats_expense: 'Perbelanjaan',
stats_net: 'Bersih',
@@ -933,7 +960,7 @@ const i18n = createI18n({
submit_failed: 'Gagal, sila cuba lagi',
file_must_be_image: 'Sila muat naik fail imej',
file_too_large: 'Fail melebihi 10MB',
status_pending: 'Menunggu',
status_pending: 'Memproses',
status_approved: 'Diluluskan',
status_rejected: 'Ditolak',
no_orders: 'Tiada rekod topup',

View File

@@ -1,6 +1,11 @@
export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'wallet.tx_deposit',
ADMIN_DEPOSIT: 'wallet.tx_admin_deposit',
AGENT_DEPOSIT: 'wallet.tx_agent_deposit',
INITIAL_DEPOSIT: 'wallet.tx_admin_deposit',
MANUAL_WITHDRAW: 'wallet.tx_withdraw',
ADMIN_WITHDRAW: 'wallet.tx_admin_withdraw',
AGENT_WITHDRAW: 'wallet.tx_agent_withdraw',
MANUAL_ADJUST: 'wallet.tx_adjust',
BET_FREEZE: 'wallet.tx_bet_freeze',
BET_DEDUCT: 'wallet.tx_bet_deduct',
@@ -16,13 +21,36 @@ export const TX_KEY_MAP: Record<string, string> = {
RESETTLE_REVERSE: 'wallet.tx_resettle',
DEPOSIT: 'wallet.tx_deposit',
WITHDRAW: 'wallet.tx_withdraw',
PLAYER_DEPOSIT: 'wallet.tx_deposit',
PLAYER_DEPOSIT: 'wallet.tx_player_deposit',
};
export function txTypeKey(type: string): string {
return TX_KEY_MAP[type.toUpperCase()] ?? '';
}
export function txDisplayType(tx: { displayType?: string; transactionType: string }): string {
return tx.displayType?.trim() || tx.transactionType;
}
export function txSummaryLabel(
tx: {
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
},
t: (key: string, params?: Record<string, unknown>) => string,
): string {
if (tx.summaryKind === 'opening_bonus') {
return t('wallet.summary_opening_bonus');
}
const summary = tx.summary?.trim();
if (!summary) return '';
if (tx.referenceType === 'BET') {
return t('wallet.summary_bet', { betNo: summary });
}
return summary;
}
export function isDepositType(type: string): boolean {
const t = type.toUpperCase();
return (t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT') && !isCashbackType(type);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -31,7 +31,13 @@ type CashbackRecord = {
};
const items = ref<CashbackRecord[]>([]);
const loading = ref(true);
const loading = ref(false);
const initialLoading = ref(true);
const page = ref(1);
const hasMore = ref(true);
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const totalAmount = computed(() =>
items.value.reduce((sum, row) => sum + Math.abs(parseFloat(row.amount) || 0), 0),
@@ -61,25 +67,59 @@ function formatTime(v: string | null) {
});
}
async function fetchRecords() {
async function fetchRecords(p = 1) {
if (loading.value) return;
loading.value = true;
try {
const { data } = await api.get('/player/cashbacks');
items.value = data.data ?? [];
const { data } = await api.get('/player/cashbacks', { params: { page: p } });
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
const newItems = result.items ?? [];
if (p === 1) {
items.value = newItems;
} else {
items.value = [...items.value, ...newItems];
}
const pageSize = result.pageSize ?? 20;
hasMore.value = newItems.length >= pageSize;
page.value = p;
} catch {
items.value = [];
if (p === 1) items.value = [];
} finally {
loading.value = false;
initialLoading.value = false;
}
}
useOnLocaleChange(fetchRecords);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchRecords,
useOnLocaleChange(() => {
items.value = [];
page.value = 1;
hasMore.value = true;
initialLoading.value = true;
fetchRecords(1);
});
onMounted(fetchRecords);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await fetchRecords(1); },
});
onMounted(() => {
fetchRecords(1);
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
fetchRecords(page.value + 1);
}
},
{ rootMargin: '200px' },
);
if (sentinel.value) observer.observe(sentinel.value);
});
onUnmounted(() => {
observer?.disconnect();
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
@@ -144,6 +184,16 @@ const pullIndicatorStyle = () => ({
</article>
</div>
<div ref="sentinel" class="sentinel" />
<div v-if="loading && items.length > 0" class="load-more-spinner">
<GoldSpinner :size="24" />
</div>
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
{{ t('common.no_more') }}
</div>
<div v-else class="empty">
<p class="empty-title">{{ t('cashback.empty') }}</p>
<p class="empty-hint">{{ t('cashback.empty_hint') }}</p>
@@ -351,4 +401,23 @@ const pullIndicatorStyle = () => ({
line-height: 1.5;
color: #666;
}
.sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: 20px 0 8px;
}
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
}
</style>

View File

@@ -9,7 +9,7 @@ import RobotVerify from '../components/RobotVerify.vue';
import loginBg from '../assets/images/h5bg.png';
const { t } = useI18n();
const { initFromUser } = useAppLocale();
const { syncLocaleToBackend } = useAppLocale();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
@@ -29,7 +29,7 @@ async function submit() {
error.value = '';
try {
await auth.login(username.value, password.value);
initFromUser(auth.user?.locale);
await syncLocaleToBackend();
const redirectTo = (route.query.redirect as string) || '/';
router.push(redirectTo);
} catch (e: unknown) {

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import { formatMoney } from '../utils/localeDisplay';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
@@ -21,49 +21,49 @@ const profile = ref<{
wallet?: { availableBalance: string; frozenBalance: string };
} | null>(null);
const loading = ref(true);
const error = ref(false);
const rulesExpanded = ref(false);
const cashbackTotal = ref('0');
const displayAmount = ref(0);
const animating = ref(false);
function amountValue(value: unknown): number {
if (value == null) return 0;
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function runCountUp(target: number) {
const duration = 3000;
const start = performance.now();
animating.value = true;
function step(now: number) {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 5);
displayAmount.value = target * eased;
if (progress < 1) {
requestAnimationFrame(step);
} else {
displayAmount.value = target;
animating.value = false;
}
}
requestAnimationFrame(step);
}
const displayedBalance = computed(() =>
animating.value
? formatMoney(displayAmount.value, locale.value)
: formatMoneyCompact(profile.value?.wallet?.availableBalance, locale.value),
);
async function fetchProfile() {
const { data } = await api.get('/player/profile');
profile.value = data.data;
initFromUser(data.data?.locale);
runCountUp(amountValue(data.data?.wallet?.availableBalance));
loading.value = true;
error.value = false;
try {
const { data } = await api.get('/player/profile');
profile.value = data.data;
initFromUser(data.data?.locale);
// Fetch cashback total in parallel
void fetchCashbackTotal();
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
onMounted(fetchProfile);
async function fetchCashbackTotal() {
try {
const { data } = await api.get('/player/wallet/transactions/stats');
const byType = data.data?.byType ?? [];
let sum = 0;
for (const g of byType) {
if (['CASHBACK', 'CASHBACK_DEPOSIT'].includes(g.transactionType?.toUpperCase())) {
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
}
}
cashbackTotal.value = sum.toString();
} catch {
// Ignore errors, keep default value
}
}
onMounted(() => {
void fetchProfile();
});
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await fetchProfile(); },
@@ -82,6 +82,19 @@ function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profile.value?.wallet?.availableBalance, locale.value),
);
const balanceAmountClass = computed(() => {
const len = balanceDisplay.value.length;
if (len <= 10) return 'balance-amount--xl';
if (len <= 13) return 'balance-amount--lg';
if (len <= 16) return 'balance-amount--md';
if (len <= 19) return 'balance-amount--sm';
return 'balance-amount--xs';
});
</script>
<template>
@@ -93,30 +106,55 @@ function logout() {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" :active="true" />
</div>
<div v-else-if="error" class="error-state">
<p class="error-text">{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
</div>
<template v-else>
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<img src="/logo.png" alt="TheBet365" class="wallet-card-logo" />
<div class="wallet-banner-info">
<div class="card-balance-block">
<span class="card-kicker">
<LocaleFlag :locale="locale" :size="14" />
{{ t('wallet.balance') }}
</span>
<div class="card-balance-row">
<p class="card-balance">{{ displayedBalance }}</p>
<button type="button" class="card-recharge-btn" @click.stop="router.push('/wallet/recharge')">
{{ t('recharge.title') }}
</button>
<div class="wallet-banner-scrim" aria-hidden="true" />
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
<div class="bank-card-chip" aria-hidden="true">
<span /><span /><span />
</div>
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<div class="bank-card-brand-text">
<span class="brand-name">THEBET365</span>
<span class="bank-card-type">MEMBER</span>
</div>
</div>
<RouterLink to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</RouterLink>
</div>
<div class="card-foot">
<div class="card-holder">
<span class="card-kicker">{{ t('wallet.card_holder') }}</span>
<span class="card-name">{{ profile?.username }}</span>
<div class="bank-card-balance">
<span class="bank-card-label">当前余额</span>
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
</div>
<div class="bank-card-footer">
<div class="bank-card-field">
<span class="bank-card-label">持卡人</span>
<span class="bank-card-holder">{{ profile?.username }}</span>
</div>
<div class="card-pending">
<span class="card-kicker">{{ t('wallet.unsettled') }}</span>
<span class="card-pending-val">{{ formatMoney(profile?.wallet?.frozenBalance, locale) }}</span>
<div class="bank-card-field bank-card-field--center">
<span class="bank-card-label">累计返水</span>
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="bank-card-field bank-card-field--right">
<span class="bank-card-label">未结算</span>
<span class="bank-card-stat">{{ formatMoney(profile?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
@@ -124,17 +162,46 @@ function logout() {
<section class="settings-group">
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_all') }}</span>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 5H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V9" />
<path d="M9 5a2 2 0 012-2h2a2 2 0 012 2v0M9 12h6M9 16h4" />
</svg>
<span class="cell-label">{{ t('wallet.view_all') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_cashbacks') }}</span>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4l2.5 2.5" />
</svg>
<span class="cell-label">{{ t('wallet.view_cashbacks') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--gold-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 8v8M8 12h8" />
<circle cx="12" cy="12" r="8" />
</svg>
<span class="cell-label">{{ t('recharge.history_title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-label">{{ t('profile.edit') }}</span>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</svg>
<span class="cell-label">{{ t('profile.edit') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
@@ -145,7 +212,13 @@ function logout() {
:aria-expanded="rulesExpanded"
@click="rulesExpanded = !rulesExpanded"
>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M7 4h10v16H7z" />
<path d="M10 8h6M10 12h6M10 16h4" />
</svg>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
</span>
<span class="cell-chevron" :class="{ open: rulesExpanded }" aria-hidden="true"></span>
</button>
<div v-show="rulesExpanded" class="rules-body">
@@ -159,6 +232,10 @@ function logout() {
<div class="settings-cell settings-cell--stack">
<div class="cell-head">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 4 6 4 9s-1.5 6.2-4 9M12 3c-2.5 2.8-4 6-4 9s1.5 6.2 4 9" />
</svg>
<span class="cell-label">{{ t('profile.language') }}</span>
</div>
<div class="lang-segment" role="group" :aria-label="t('profile.language')">
@@ -180,6 +257,7 @@ function logout() {
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</template>
</div>
</template>
@@ -196,173 +274,292 @@ function logout() {
padding: 8px 0 12px;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 60vh;
}
.error-text {
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.retry-btn {
padding: 8px 24px;
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.retry-btn:active {
background: rgba(200, 168, 78, 0.15);
}
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 12px;
margin-left: -5px;
margin-right: -5px;
line-height: 0;
box-sizing: border-box;
aspect-ratio: 2 / 1;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 3px 10px rgba(0, 0, 0, 0.28),
0 1px 3px rgba(0, 0, 0, 0.18);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: auto;
height: 100%;
display: block;
}
.wallet-card-logo {
position: absolute;
top: 9%;
right: 5.5%;
z-index: 2;
width: clamp(42px, 12.8vw, 64px);
height: auto;
object-fit: contain;
object-fit: cover;
object-position: center center;
transform: scale(1.12);
transform-origin: center center;
filter: brightness(0.86);
pointer-events: none;
filter:
drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 5px rgba(212, 175, 55, 0.32))
drop-shadow(0 0 10px rgba(240, 216, 117, 0.18));
user-select: none;
}
.wallet-banner-info {
.wallet-banner-scrim {
position: absolute;
inset: 11% 12% 9% 19%;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.18) 42%,
rgba(0, 0, 0, 0.46) 100%
);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
gap: clamp(14px, 3.5vw, 20px);
line-height: normal;
box-sizing: border-box;
pointer-events: none;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei UI', 'Helvetica Neue', Arial, sans-serif;
font-stretch: semi-expanded;
padding: 14px 16px 12px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;
}
.card-kicker {
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-shrink: 0;
}
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.bank-card-chip {
width: 34px;
height: 24px;
border-radius: 5px;
background: linear-gradient(135deg, #e8c96a 0%, #c9a227 40%, #a8841f 100%);
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
padding: 0 7px;
flex-shrink: 0;
}
.bank-card-chip span {
display: block;
height: 2px;
border-radius: 1px;
background: rgba(0, 0, 0, 0.25);
}
.bank-card-chip span:nth-child(2) { width: 70%; }
.bank-card-chip span:nth-child(3) { width: 50%; }
.brand-logo {
width: 22px;
height: 22px;
flex-shrink: 0;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-name {
font-size: 11px;
font-weight: 700;
color: #fff;
letter-spacing: 0.14em;
line-height: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(212, 175, 55, 0.65);
font-style: italic;
line-height: 1;
}
.bank-card-label {
display: block;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.recharge-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: clamp(10px, 2.5vw, 11px);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(180, 180, 185, 0.92);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.75),
0 2px 5px rgba(0, 0, 0, 0.35);
gap: 3px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(212, 175, 55, 0.35);
color: var(--primary-light);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1;
text-decoration: none;
backdrop-filter: blur(6px);
transition: background 0.15s ease;
}
.card-balance-block {
.recharge-btn:active {
background: rgba(212, 175, 55, 0.15);
}
.recharge-icon {
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.bank-card-balance {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(3px, 1vw, 6px);
min-height: clamp(44px, 12vw, 60px);
min-height: 0;
padding: 2px 0;
gap: 5px;
}
.card-balance {
.bank-card-number {
margin: 0;
font-size: clamp(22px, 6.8vw, 36px);
font-weight: 900;
letter-spacing: -0.02em;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-weight: 700;
font-style: normal;
font-variant-numeric: tabular-nums;
line-height: 1.05;
color: var(--primary-light);
line-height: 1.1;
letter-spacing: 0.04em;
white-space: nowrap;
background: var(--gradient-gold);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter:
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.85))
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 10px rgba(212, 175, 55, 0.22));
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}
.card-balance-row {
display: flex;
align-items: center;
.balance-amount {
margin: 0;
}
.balance-amount--xl { font-size: clamp(24px, 6.2vw, 30px); }
.balance-amount--lg { font-size: clamp(21px, 5.6vw, 26px); }
.balance-amount--md { font-size: clamp(18px, 4.9vw, 22px); }
.balance-amount--sm { font-size: clamp(16px, 4.2vw, 19px); }
.balance-amount--xs { font-size: clamp(14px, 3.6vw, 16px); }
.bank-card-footer {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.card-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-top: auto;
padding-top: 2px;
}
.card-holder,
.card-pending {
.bank-card-field {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
min-width: 0;
}
.card-pending {
align-items: flex-end;
text-align: right;
}
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.card-name {
font-size: clamp(12px, 3.2vw, 15px);
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 44vw;
background: linear-gradient(180deg, #fff6d8 0%, #e8c96a 38%, #c9a227 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter:
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 2px 5px rgba(0, 0, 0, 0.4));
}
.card-pending-val {
font-size: clamp(12px, 3.2vw, 14px);
font-weight: 900;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
color: rgba(210, 210, 215, 0.95);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.75),
0 2px 5px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.card-recharge-btn {
z-index: 3;
background: linear-gradient(135deg, #f0d060, #d4a830);
color: #1a1a1a;
border: none;
border-radius: 16px;
padding: 5px 14px;
.bank-card-holder {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.4),
0 0 12px rgba(212, 175, 55, 0.25);
pointer-events: auto;
transition: transform 0.1s;
white-space: nowrap;
flex-shrink: 0;
font-weight: 700;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
word-break: break-all;
}
.card-recharge-btn:active {
transform: scale(0.95);
.bank-card-stat {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #fff;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
@@ -429,9 +626,28 @@ function logout() {
min-height: auto;
}
.cell-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cell-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-muted);
}
.settings-cell--gold-entry .cell-icon {
color: var(--primary-light);
}
.cell-head {
display: flex;
align-items: center;
gap: 10px;
}
.cell-label {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -25,24 +25,58 @@ interface DepositOrder {
}
const items = ref<DepositOrder[]>([]);
const loading = ref(true);
const loading = ref(false);
const initialLoading = ref(true);
const page = ref(1);
const total = ref(0);
const hasMore = ref(true);
async function fetchOrders() {
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
async function fetchOrders(p = 1) {
if (loading.value) return;
loading.value = true;
try {
const { data } = await api.get('/player/deposit-orders', { params: { page: page.value } });
const result = data.data ?? { items: [], total: 0 };
items.value = result.items ?? [];
const { data } = await api.get('/player/deposit-orders', { params: { page: p } });
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
const newItems = result.items ?? [];
if (p === 1) {
items.value = newItems;
} else {
items.value = [...items.value, ...newItems];
}
total.value = result.total ?? 0;
const pageSize = result.pageSize ?? 20;
hasMore.value = newItems.length >= pageSize && items.value.length < total.value;
page.value = p;
} catch { /* */ } finally {
loading.value = false;
initialLoading.value = false;
}
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchOrders,
onRefresh: async () => { await fetchOrders(1); },
});
onMounted(() => {
fetchOrders(1);
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
fetchOrders(page.value + 1);
}
},
{ rootMargin: '200px' },
);
if (sentinel.value) observer.observe(sentinel.value);
});
onUnmounted(() => {
observer?.disconnect();
});
function statusClass(s: string) {
@@ -123,6 +157,16 @@ onMounted(fetchOrders);
</div>
</div>
</div>
<div ref="sentinel" class="sentinel" />
<div v-if="loading && items.length > 0" class="load-more-spinner">
<GoldSpinner :size="24" />
</div>
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
{{ t('common.no_more') }}
</div>
</template>
</div>
</template>
@@ -205,4 +249,23 @@ onMounted(fetchOrders);
border-left: 2px solid rgba(212, 175, 55, 0.3);
}
.reject-reason { margin-top: 8px; font-size: 12px; color: #f56c6c; background: #2a1515; padding: 6px 10px; border-radius: 6px; }
.sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: 20px 0 8px;
}
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
}
</style>

View File

@@ -154,6 +154,12 @@ function copyText(text: string) {
navigator.clipboard?.writeText(text);
}
function formatCardNumber(num: string | null): string {
if (!num) return '—';
const digits = num.replace(/\s/g, '');
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim();
}
onMounted(fetchMethods);
</script>
@@ -202,25 +208,43 @@ onMounted(fetchMethods);
</div>
<div v-else class="empty-methods">{{ t('recharge.no_methods') }}</div>
<div v-if="selectedMethod" class="method-info">
<template v-if="selectedMethod.methodType === 'BANK'">
<div class="info-row">
<span class="info-label">{{ t('recharge.bank_name') }}</span>
<span class="info-value">{{ selectedMethod.bankName }}</span>
<div v-if="selectedMethod && selectedMethod.methodType === 'BANK'" class="bank-card-wrap">
<div class="bank-card-face">
<div class="bank-card-shine" aria-hidden="true" />
<div class="bank-card-deco bank-card-deco--1" aria-hidden="true" />
<div class="bank-card-deco bank-card-deco--2" aria-hidden="true" />
<div class="bank-card-top">
<div class="bank-card-chip" aria-hidden="true">
<span /><span /><span />
</div>
<div class="bank-card-bank">{{ selectedMethod.bankName }}</div>
</div>
<div class="info-row">
<span class="info-label">{{ t('recharge.account_holder') }}</span>
<span class="info-value">{{ selectedMethod.accountHolder }}</span>
<div class="bank-card-number-row">
<span class="bank-card-number">{{ formatCardNumber(selectedMethod.accountNumber) }}</span>
<button
type="button"
class="bank-card-copy"
:aria-label="t('recharge.account_number')"
@click="copyText(selectedMethod.accountNumber || '')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
<div class="info-row">
<span class="info-label">{{ t('recharge.account_number') }}</span>
<span class="info-value copyable" @click="copyText(selectedMethod!.accountNumber || '')">
{{ selectedMethod.accountNumber }}
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
<div class="bank-card-bottom">
<div class="bank-card-holder">
<span class="bank-card-holder-label">{{ t('recharge.account_holder') }}</span>
<span class="bank-card-holder-name">{{ selectedMethod.accountHolder }}</span>
</div>
<div class="bank-card-type">DEBIT</div>
</div>
</template>
<template v-else>
</div>
</div>
<div v-else-if="selectedMethod" class="method-info">
<template v-if="selectedMethod.methodType !== 'BANK'">
<div class="info-row">
<span class="info-label">{{ t('recharge.usdt_address') }}</span>
<span class="info-value copyable" @click="copyText(selectedMethod!.usdtAddress || '')">
@@ -317,6 +341,203 @@ onMounted(fetchMethods);
.pill-sub { font-size: 10px; color: var(--text-muted); }
.empty-methods { text-align: center; color: var(--text-muted); padding: 20px; font-size: 12px; }
.bank-card-wrap {
margin-bottom: 16px;
perspective: 800px;
}
.bank-card-face {
position: relative;
aspect-ratio: 2 / 1;
border-radius: 14px;
padding: 14px 18px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(212, 175, 55, 0.22), transparent 55%),
radial-gradient(ellipse 50% 50% at 0% 100%, rgba(212, 175, 55, 0.1), transparent 50%),
linear-gradient(135deg, #2a2218 0%, #1a1610 35%, #12100c 70%, #0a0908 100%);
border: 1px solid rgba(212, 175, 55, 0.35);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.55),
0 2px 8px rgba(212, 175, 55, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.bank-card-shine {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.04) 45%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.04) 55%,
transparent 70%
);
pointer-events: none;
}
.bank-card-deco {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(212, 175, 55, 0.12);
pointer-events: none;
}
.bank-card-deco--1 {
width: 180px;
height: 180px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle, rgba(212, 175, 55, 0.08), transparent 70%);
}
.bank-card-deco--2 {
width: 100px;
height: 100px;
right: 40px;
bottom: 20px;
border-color: rgba(212, 175, 55, 0.08);
}
.bank-card-top,
.bank-card-number-row,
.bank-card-bottom {
position: relative;
z-index: 1;
}
.bank-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.bank-card-chip {
width: 36px;
height: 26px;
border-radius: 6px;
background: linear-gradient(135deg, #e8c96a 0%, #c9a227 40%, #a8841f 100%);
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 0 8px;
flex-shrink: 0;
}
.bank-card-chip span {
display: block;
height: 2px;
border-radius: 1px;
background: rgba(0, 0, 0, 0.25);
}
.bank-card-chip span:nth-child(2) { width: 70%; }
.bank-card-chip span:nth-child(3) { width: 50%; }
.bank-card-bank {
font-size: 15px;
font-weight: 800;
color: #fff;
text-align: right;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
line-height: 1.3;
max-width: 58%;
word-break: break-all;
}
.bank-card-number-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 4px 0 2px;
}
.bank-card-number {
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-size: clamp(15px, 4.2vw, 19px);
font-weight: 700;
letter-spacing: 0.14em;
color: var(--primary-light);
text-shadow: 0 0 12px rgba(240, 216, 117, 0.25);
word-break: break-all;
line-height: 1.3;
}
.bank-card-copy {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(212, 175, 55, 0.35);
background: rgba(0, 0, 0, 0.25);
color: var(--primary-light);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.bank-card-copy svg {
width: 16px;
height: 16px;
}
.bank-card-copy:active {
opacity: 0.65;
background: rgba(212, 175, 55, 0.12);
}
.bank-card-bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.bank-card-holder {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bank-card-holder-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
}
.bank-card-holder-name {
font-size: 14px;
font-weight: 700;
color: #fff;
letter-spacing: 0.04em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
word-break: break-all;
}
.bank-card-type {
flex-shrink: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
color: rgba(212, 175, 55, 0.55);
font-style: italic;
}
.method-info {
background: rgba(17, 17, 17, 0.9);
border-radius: 8px; padding: 10px 12px;

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey } from '../utils/walletTx';
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -14,6 +14,10 @@ const { t, locale } = useI18n();
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
createdAt: string;
transactionId: string;
@@ -57,13 +61,17 @@ async function fetchCashbackTotal() {
}
}
function txLabel(type: string): string {
const key = txTypeKey(type);
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return type;
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function goDetail(tx: Transaction) {
@@ -186,7 +194,10 @@ const pullIndicatorStyle = () => ({
@click="goDetail(tx)"
>
<div class="tx-main">
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
<div class="tx-text">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(tx.amount, locale) }}
</span>
@@ -321,6 +332,22 @@ const pullIndicatorStyle = () => ({
gap: 12px;
}
.tx-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tx-summary {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-meta {
display: flex;
justify-content: space-between;

View File

@@ -4,13 +4,16 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey, isCashbackType } from '../utils/walletTx';
import { txTypeKey, isCashbackType, txDisplayType, txSummaryLabel } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
type TransactionDetail = {
transactionId: string;
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
amount: string;
balanceBefore: string;
balanceAfter: string;
@@ -21,6 +24,7 @@ type TransactionDetail = {
remark: string | null;
createdAt: string;
betNo: string | null;
cashbackBatchNo?: string | null;
};
const route = useRoute();
@@ -67,6 +71,16 @@ function txLabel(type: string): string {
return type;
}
const displayTypeLabel = computed(() => {
if (!tx.value) return '';
return txLabel(txDisplayType(tx.value));
});
const summaryText = computed(() => {
if (!tx.value) return '';
return txSummaryLabel(tx.value, t);
});
const amountClass = computed(() => {
if (!tx.value) return '';
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
@@ -87,8 +101,8 @@ const isCashbackTx = computed(
);
const cashbackBatchNo = computed(() => {
if (!isCashbackTx.value || !tx.value?.referenceId) return null;
return tx.value.referenceId;
if (!isCashbackTx.value) return null;
return tx.value?.cashbackBatchNo ?? tx.value?.referenceId ?? null;
});
const referenceLabel = computed(() => {
@@ -132,7 +146,8 @@ function goCashbackDetail() {
<template v-else>
<div class="hero" :class="amountClass">
<span class="hero-type">{{ txLabel(tx.transactionType) }}</span>
<span class="hero-type">{{ displayTypeLabel }}</span>
<span v-if="summaryText" class="hero-summary">{{ summaryText }}</span>
<span class="hero-amount">{{ formatMoney(tx.amount, locale) }}</span>
<span class="hero-time">{{ formattedTime }}</span>
</div>
@@ -266,6 +281,14 @@ function goCashbackDetail() {
color: #888;
}
.hero-summary {
font-size: 13px;
font-weight: 600;
color: #bbb;
max-width: 100%;
word-break: break-all;
}
.hero-amount {
font-size: 34px;
font-weight: 900;

View File

@@ -3,8 +3,8 @@ import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey } from '../utils/walletTx';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import { txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -13,22 +13,31 @@ const { t, locale } = useI18n();
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
createdAt: string;
transactionId: string;
};
const items = ref<Transaction[]>([]);
const cashbackTotal = ref<string>('0');
const loading = ref(true);
const PREVIEW_COUNT = 15;
function txLabel(type: string): string {
const key = txTypeKey(type);
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return type;
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function goDetail(tx: Transaction) {
@@ -36,20 +45,21 @@ function goDetail(tx: Transaction) {
router.push(`/wallet/transactions/${tx.transactionId}`);
}
function goWalletDetail() {
router.push('/wallet/detail');
}
function goCashbacks() {
router.push('/wallet/cashbacks');
}
async function fetchTransactions() {
async function fetchData() {
loading.value = true;
try {
const { data } = await api.get('/player/wallet/transactions', { params: { page: 1 } });
const result = data.data ?? { items: [] };
const [txRes, cbRes] = await Promise.all([
api.get('/player/wallet/transactions', { params: { page: 1 } }),
api.get('/player/cashbacks').catch(() => ({ data: { data: { items: [], totalAmount: '0' } } })),
]);
const result = txRes.data.data ?? { items: [] };
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
const cbData = cbRes.data?.data;
cashbackTotal.value = cbData?.totalAmount ?? cbData?.items?.reduce((s: number, r: { amount: string }) => s + Math.abs(parseFloat(r.amount) || 0), 0)?.toString() ?? '0';
} catch {
/* ignore */
} finally {
@@ -58,10 +68,10 @@ async function fetchTransactions() {
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchTransactions,
onRefresh: fetchData,
});
onMounted(fetchTransactions);
onMounted(fetchData);
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
@@ -92,7 +102,7 @@ const pullIndicatorStyle = () => ({
v-if="items.length"
type="button"
class="more-link"
@click="goWalletDetail"
@click="router.push('/wallet/detail')"
>{{ t('wallet.view_all') }} &#x203A;</button>
</div>
@@ -105,7 +115,10 @@ const pullIndicatorStyle = () => ({
@click="goDetail(tx)"
>
<div class="tx-main">
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
<div class="tx-text">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(tx.amount, locale) }}
</span>
@@ -184,6 +197,22 @@ const pullIndicatorStyle = () => ({
gap: 12px;
}
.tx-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tx-summary {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-meta {
display: flex;
justify-content: space-between;

View File

@@ -124,6 +124,16 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Match cannot be edited in current status',
'ms-MY': 'Perlawanan tidak boleh diedit dalam status semasa',
},
MATCH_NOT_REOPENABLE: {
'zh-CN': '当前状态不可解除封盘',
'en-US': 'Match cannot be reopened in current status',
'ms-MY': 'Perlawanan tidak boleh dibuka semula dalam status semasa',
},
MATCH_REOPEN_KICKOFF_REQUIRED: {
'zh-CN': '开赛时间已过,请设置新的未来开赛时间',
'en-US': 'Kickoff has passed; set a new future start time',
'ms-MY': 'Masa mula telah berlalu; tetapkan masa mula baharu pada masa hadapan',
},
OUTRIGHT_DELETE_FORBIDDEN: {
'zh-CN': '冠军盘不可删除',
'en-US': 'Outright events cannot be deleted',
@@ -469,6 +479,16 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Use credit limit when promoting to agent, not initial balance',
'ms-MY': 'Guna had kredit apabila naik taraf ke ejen, bukan baki awal',
},
INITIAL_DEPOSIT_REMARK_REQUIRED: {
'zh-CN': '有初始余额时必须选择上分流水说明',
'en-US': 'Ledger note is required when initial balance > 0',
'ms-MY': 'Nota ledger diperlukan apabila baki awal > 0',
},
INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID: {
'zh-CN': '自定义流水说明至少 2 个字符',
'en-US': 'Custom ledger note must be at least 2 characters',
'ms-MY': 'Nota ledger tersuai mesti sekurang-kurangnya 2 aksara',
},
TIER2_REQUIRES_PARENT_AGENT: {
'zh-CN': '二级代理必须指定上级代理',
'en-US': 'Tier-2 agent must specify parent agent',

View File

@@ -126,6 +126,7 @@ export * from './locale';
export * from './builtinPlayers';
export * from './playerLocale';
export * from './playerUsername';
export * from './initial-depositRemark';
export interface ApiResponse<T = unknown> {
success: boolean;

View File

@@ -0,0 +1,45 @@
export type InitialDepositRemarkKind = 'daily' | 'opening_bonus' | 'custom';
export type InitialDepositOperator = 'admin' | 'agent';
export const OPENING_BONUS_REMARK = '开户初始余额';
export const ADMIN_DAILY_DEPOSIT_REMARK = '管理员上分';
export const AGENT_DAILY_DEPOSIT_REMARK = '代理上分';
export type InitialDepositRemarkValidationError =
| 'INITIAL_DEPOSIT_REMARK_REQUIRED'
| 'INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID';
export function dailyDepositRemark(operator: InitialDepositOperator): string {
return operator === 'admin' ? ADMIN_DAILY_DEPOSIT_REMARK : AGENT_DAILY_DEPOSIT_REMARK;
}
export function resolveInitialDepositRemark(
kind: InitialDepositRemarkKind,
custom: string | undefined,
operator: InitialDepositOperator,
): string {
if (kind === 'daily') return dailyDepositRemark(operator);
if (kind === 'opening_bonus') return OPENING_BONUS_REMARK;
const trimmed = custom?.trim() ?? '';
if (trimmed.length < 2) {
throw new Error('INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID');
}
return trimmed;
}
export function validateInitialDepositRemark(
initialDeposit: number,
remark: string | undefined,
operator: InitialDepositOperator,
): { ok: true; remark: string } | { ok: false; code: InitialDepositRemarkValidationError } {
if (initialDeposit <= 0) {
return { ok: true, remark: remark?.trim() ?? '' };
}
const r = remark?.trim();
if (!r) return { ok: false, code: 'INITIAL_DEPOSIT_REMARK_REQUIRED' };
const daily = dailyDepositRemark(operator);
if (r === daily || r === OPENING_BONUS_REMARK) return { ok: true, remark: r };
if (r.length < 2) return { ok: false, code: 'INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID' };
return { ok: true, remark: r };
}