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: {
|
||||
|
||||
Reference in New Issue
Block a user