feat: 开户备注、账单展示优化与后台代理管理增强
- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示 - 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分 - 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端 - 后台代理/玩家表格操作栏重构,充值订单增加备注列 - 前台个人中心、充值、账单与验证码组件体验优化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user