feat: 开户备注、账单展示优化与后台代理管理增强
- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示 - 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分 - 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端 - 后台代理/玩家表格操作栏重构,充值订单增加备注列 - 前台个人中心、充值、账单与验证码组件体验优化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
76
apps/admin/src/components/AdminAgentRowActions.vue
Normal file
76
apps/admin/src/components/AdminAgentRowActions.vue
Normal 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>
|
||||
97
apps/admin/src/components/AdminNavIcon.vue
Normal file
97
apps/admin/src/components/AdminNavIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
17
apps/admin/src/components/AdminTableWrap.vue
Normal file
17
apps/admin/src/components/AdminTableWrap.vue
Normal 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>
|
||||
47
apps/admin/src/components/InitialDepositRemarkField.vue
Normal file
47
apps/admin/src/components/InitialDepositRemarkField.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
53
apps/admin/src/composables/useAdminTableRowActionsLayout.ts
Normal file
53
apps/admin/src/composables/useAdminTableRowActionsLayout.ts
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
apps/admin/src/utils/initial-depositRemark.ts
Normal file
21
apps/admin/src/utils/initial-depositRemark.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 '—';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 为 CommonJS,Vite 无法按命名导出加载;直连源码
|
||||
'@thebet365/shared': resolve(__dirname, '../../packages/shared/src/index.ts'),
|
||||
},
|
||||
dedupe: ['echarts', 'vue-echarts', 'vue'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -432,6 +432,7 @@ export class DepositService {
|
||||
reviewerId: operatorId,
|
||||
reviewedAt: new Date(),
|
||||
rejectReason: reason,
|
||||
remark: reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -69,10 +69,10 @@ watch(
|
||||
() => auth.token,
|
||||
(token) => {
|
||||
// 首页数据(公告、热门赛事)对所有人公开,始终加载
|
||||
void loadPlayerHome();
|
||||
void loadPlayerHome(true);
|
||||
// 个人资料仅登录用户需要
|
||||
if (token) {
|
||||
void loadProfile();
|
||||
void loadProfile(true);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') }} ›</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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
packages/shared/src/initial-depositRemark.ts
Normal file
45
packages/shared/src/initial-depositRemark.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user