feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
apps/admin/src/components/AdminDetailGrid.vue
Normal file
24
apps/admin/src/components/AdminDetailGrid.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
columns?: 2 | 3 | 4;
|
||||
}>(),
|
||||
{ columns: 3 },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-detail-grid" :style="{ '--detail-cols': columns }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--detail-cols, 3), minmax(0, 1fr));
|
||||
column-gap: 18px;
|
||||
row-gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
75
apps/admin/src/components/AdminDetailItem.vue
Normal file
75
apps/admin/src/components/AdminDetailItem.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
/** 占满整行 */
|
||||
full?: boolean;
|
||||
/** 跨列数(默认 1) */
|
||||
span?: 2 | 3;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-detail-item"
|
||||
:class="{
|
||||
'admin-detail-item--full': full,
|
||||
'admin-detail-item--span-2': span === 2,
|
||||
'admin-detail-item--span-3': span === 3,
|
||||
}"
|
||||
>
|
||||
<div class="admin-detail-item__label">{{ label }}</div>
|
||||
<div class="admin-detail-item__value">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5em, max-content) minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-detail-item--span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.admin-detail-item--span-3,
|
||||
.admin-detail-item--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-detail-item__label {
|
||||
font-size: 12px;
|
||||
color: #737373;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-detail-item__value {
|
||||
font-size: 13px;
|
||||
color: #f5f5f5;
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-detail-item__value :deep(.field-hint),
|
||||
.admin-detail-item__value :deep(.admin-detail-hint) {
|
||||
display: inline;
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #737373;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-detail-item--full .admin-detail-item__value :deep(.admin-detail-hint),
|
||||
.admin-detail-item--span-2 .admin-detail-item__value :deep(.admin-detail-hint),
|
||||
.admin-detail-item--span-3 .admin-detail-item__value :deep(.admin-detail-hint) {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
@@ -133,24 +133,24 @@ onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||||
|
||||
/* 管理顶栏 */
|
||||
.admin-locale.admin .admin-locale-trigger {
|
||||
background: #0d0d0d;
|
||||
color: var(--green-text);
|
||||
border: 1px solid var(--green-border);
|
||||
background: transparent;
|
||||
color: #a3a3a3;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-menu {
|
||||
background: #0f1411;
|
||||
border: 1px solid var(--green-border);
|
||||
background: #141414;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-option {
|
||||
color: #8ab89a;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-option:hover,
|
||||
.admin-locale.admin .admin-locale-option.active {
|
||||
background: var(--green-surface);
|
||||
color: var(--green-text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 登录页:与玩家端金色主题一致 */
|
||||
|
||||
82
apps/admin/src/components/AdminPlayerRowActions.vue
Normal file
82
apps/admin/src/components/AdminPlayerRowActions.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import AdminResponsiveRowActions from './AdminResponsiveRowActions.vue';
|
||||
|
||||
export type PlayerActionRow = {
|
||||
id: number | string;
|
||||
username: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
row: PlayerActionRow;
|
||||
showDetail?: boolean;
|
||||
showLedger?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showDetail: true,
|
||||
showLedger: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
detail: [];
|
||||
ledger: [];
|
||||
edit: [];
|
||||
deposit: [];
|
||||
withdraw: [];
|
||||
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 v-if="showLedger" link type="primary" size="small" @click="emit('ledger')">
|
||||
{{ t('user.action.ledger_short') }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="emit('edit')">{{ t('common.edit') }}</el-button>
|
||||
<el-button link type="success" size="small" @click="emit('deposit')">{{ t('common.topup') }}</el-button>
|
||||
<el-button link type="warning" size="small" @click="emit('withdraw')">
|
||||
{{ t('agent_portal.withdraw_btn_label') }}
|
||||
</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 v-if="showLedger" @click="emit('ledger')">{{ t('user.action.view_wallet_ledger') }}</el-dropdown-item>
|
||||
<el-dropdown-item @click="emit('edit')">{{ t('common.edit') }}</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="emit('deposit')">{{ t('common.topup') }}</el-dropdown-item>
|
||||
<el-dropdown-item @click="emit('withdraw')">
|
||||
<span class="action-warning">{{ t('agent_portal.withdraw_btn_label') }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status === 'ACTIVE'" @click="emit('freeze')">
|
||||
<span class="action-warning">{{ t('common.freeze') }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-else @click="emit('freeze')">{{ t('common.unfreeze') }}</el-dropdown-item>
|
||||
</template>
|
||||
</AdminResponsiveRowActions>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.action-warning) {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
</style>
|
||||
55
apps/admin/src/components/AdminResponsiveRowActions.vue
Normal file
55
apps/admin/src/components/AdminResponsiveRowActions.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-responsive-actions" @click.stop>
|
||||
<div class="admin-responsive-actions__inline action-btns">
|
||||
<slot name="inline" />
|
||||
</div>
|
||||
<div class="admin-responsive-actions__menu">
|
||||
<AdminRowActionsDropdown>
|
||||
<slot name="menu" />
|
||||
</AdminRowActionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-responsive-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-responsive-actions__menu {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px 6px;
|
||||
}
|
||||
|
||||
.action-btns :deep(.el-button) {
|
||||
margin: 0;
|
||||
padding: 1px 4px;
|
||||
height: auto;
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
apps/admin/src/components/AdminRowActionsDropdown.vue
Normal file
51
apps/admin/src/components/AdminRowActionsDropdown.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-row-actions" @click.stop>
|
||||
<el-dropdown trigger="click" @click.stop>
|
||||
<el-button link type="primary" size="small" @click.stop>
|
||||
{{ t('common.actions') }}
|
||||
<span class="admin-row-actions__caret" aria-hidden="true">▾</span>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="admin-row-actions__menu">
|
||||
<slot />
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-row-actions {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-row-actions__caret {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.admin-row-actions__menu :deep(.el-dropdown-menu__item) {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
padding: 6px 16px;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.admin-row-actions__menu :deep(.action-warning) {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.admin-row-actions__menu :deep(.action-danger) {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -53,15 +53,15 @@ defineProps<{
|
||||
line-height: 1.4;
|
||||
}
|
||||
.admin-subnav__link {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
color: #737373;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.admin-subnav__link:hover {
|
||||
color: #d4fde5;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.admin-subnav__current {
|
||||
color: #888;
|
||||
color: #737373;
|
||||
}
|
||||
.admin-subnav__sep {
|
||||
color: #444;
|
||||
@@ -83,10 +83,10 @@ defineProps<{
|
||||
}
|
||||
.admin-subnav__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.admin-subnav__subtitle {
|
||||
font-size: 13px;
|
||||
|
||||
@@ -122,7 +122,7 @@ function fmtFull(value: string | null | undefined) {
|
||||
color: #888;
|
||||
}
|
||||
.c-green {
|
||||
color: #67c23a;
|
||||
color: var(--gold-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.c-amber {
|
||||
|
||||
@@ -47,14 +47,14 @@ function isActive(path: string) {
|
||||
.dashboard-subnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
min-height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dashboard-subnav--embedded {
|
||||
@@ -68,24 +68,24 @@ function isActive(path: string) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dashboard-subnav__item {
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
color: #737373;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.dashboard-subnav__item:hover {
|
||||
color: #ccc;
|
||||
color: #d4d4d4;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.dashboard-subnav__item--active {
|
||||
color: var(--green-text);
|
||||
background: rgba(0, 200, 83, 0.1);
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
247
apps/admin/src/components/InviteCodePanel.vue
Normal file
247
apps/admin/src/components/InviteCodePanel.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '../api';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { buildPlayerRegisterUrl, copyText } from '../utils/invite-link';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import RatePercentInput from './RatePercentInput.vue';
|
||||
import { decimalRateToPercent, formatRatePercent, percentToDecimalRate } from '../utils/rate-percent';
|
||||
|
||||
const props = defineProps<{
|
||||
inviteCode?: string | null;
|
||||
compact?: boolean;
|
||||
readonly?: boolean;
|
||||
embedded?: boolean;
|
||||
hideHistory?: boolean;
|
||||
/** When true, code/link only appear after clicking generate (not from session/props). */
|
||||
requireGenerate?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
generated: [];
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const auth = useAuthStore();
|
||||
const loadedCode = ref<string | null>(null);
|
||||
const loadedCashbackRate = ref<string | null>(null);
|
||||
const generating = ref(false);
|
||||
const cashbackPercent = ref(0);
|
||||
const defaultCashbackLoaded = ref(false);
|
||||
|
||||
const isAdmin = computed(() => auth.isAdmin.value);
|
||||
const showCashbackField = computed(() => isAdmin.value && !props.readonly);
|
||||
|
||||
const effectiveCode = computed(() => {
|
||||
if (props.readonly) return props.inviteCode ?? null;
|
||||
if (props.requireGenerate) return loadedCode.value;
|
||||
return props.inviteCode ?? loadedCode.value;
|
||||
});
|
||||
|
||||
const registerUrl = computed(() =>
|
||||
effectiveCode.value ? buildPlayerRegisterUrl(effectiveCode.value) : '',
|
||||
);
|
||||
|
||||
const displayedCashbackRate = computed(() => {
|
||||
if (loadedCashbackRate.value != null) return loadedCashbackRate.value;
|
||||
if (showCashbackField.value) return percentToDecimalRate(cashbackPercent.value).toString();
|
||||
return null;
|
||||
});
|
||||
|
||||
async function loadDefaultCashbackRate() {
|
||||
if (!showCashbackField.value) return;
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings/cashback/platform-direct');
|
||||
const payload = data.data as { adminInviteRate?: number | string; platformDirectRate?: number | string };
|
||||
cashbackPercent.value = decimalRateToPercent(
|
||||
payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0,
|
||||
);
|
||||
} catch {
|
||||
cashbackPercent.value = 0;
|
||||
} finally {
|
||||
defaultCashbackLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
generating.value = true;
|
||||
try {
|
||||
const body = showCashbackField.value
|
||||
? { cashbackRate: percentToDecimalRate(cashbackPercent.value) }
|
||||
: undefined;
|
||||
const { data } = await api.post('/manage/invite/generate', body);
|
||||
const payload = data.data as { inviteCode?: string | null; cashbackRate?: string | null };
|
||||
const code = payload.inviteCode ?? null;
|
||||
loadedCode.value = code;
|
||||
loadedCashbackRate.value = payload.cashbackRate ?? null;
|
||||
if (auth.user.value && code) {
|
||||
auth.setSession(auth.token.value, { ...auth.user.value, inviteCode: code });
|
||||
}
|
||||
ElMessage.success(t('invite.generate_ok'));
|
||||
emit('generated');
|
||||
} catch {
|
||||
ElMessage.error(t('invite.generate_failed'));
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
if (!effectiveCode.value) return;
|
||||
const ok = await copyText(effectiveCode.value);
|
||||
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_code_ok') : t('invite.copy_failed'));
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!registerUrl.value) return;
|
||||
const ok = await copyText(registerUrl.value);
|
||||
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_link_ok') : t('invite.copy_failed'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDefaultCashbackRate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite-panel" :class="{ compact, embedded }">
|
||||
<div v-if="!embedded" class="invite-head">
|
||||
<span class="invite-title">{{ t('invite.title') }}</span>
|
||||
<span class="invite-hint">{{ t('invite.hint') }}</span>
|
||||
</div>
|
||||
<div v-if="showCashbackField && defaultCashbackLoaded" class="invite-cashback-field">
|
||||
<label class="invite-label">{{ t('invite.cashback_rate') }}</label>
|
||||
<RatePercentInput v-model="cashbackPercent" />
|
||||
<p class="invite-cashback-hint">{{ t('invite.cashback_rate_hint') }}</p>
|
||||
</div>
|
||||
<div v-if="!readonly" class="invite-actions">
|
||||
<el-button type="primary" :loading="generating" @click="generateInvite">
|
||||
{{ effectiveCode ? t('invite.regenerate_btn') : t('invite.generate_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="effectiveCode" class="invite-body">
|
||||
<div v-if="displayedCashbackRate != null" class="invite-row">
|
||||
<span class="invite-label">{{ t('invite.cashback_rate') }}</span>
|
||||
<span class="invite-rate">{{ formatRatePercent(displayedCashbackRate) }}</span>
|
||||
</div>
|
||||
<div class="invite-row">
|
||||
<span class="invite-label">{{ t('invite.code') }}</span>
|
||||
<code class="invite-code">{{ effectiveCode }}</code>
|
||||
<el-button size="small" @click="copyCode">{{ t('invite.copy_code') }}</el-button>
|
||||
</div>
|
||||
<div class="invite-row">
|
||||
<span class="invite-label">{{ t('invite.link') }}</span>
|
||||
<span class="invite-link">{{ registerUrl }}</span>
|
||||
<el-button type="primary" size="small" @click="copyLink">{{ t('invite.copy_link') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="readonly" class="invite-empty">{{ t('invite.not_generated') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.invite-panel.compact {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.invite-panel.embedded {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.invite-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.invite-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.invite-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.invite-cashback-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-cashback-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.invite-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.invite-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
min-width: 72px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.invite-rate {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.invite-link {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.invite-empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
507
apps/admin/src/components/InviteHistoryPanel.vue
Normal file
507
apps/admin/src/components/InviteHistoryPanel.vue
Normal file
@@ -0,0 +1,507 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '../api';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { buildPlayerRegisterUrl, copyText } from '../utils/invite-link';
|
||||
import AdminTableEmpty from './AdminTableEmpty.vue';
|
||||
import { formatRatePercent } from '../utils/rate-percent';
|
||||
|
||||
export interface InviteHistoryRow {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
cashbackRate: string | null;
|
||||
sponsorId: string;
|
||||
sponsorUsername: string;
|
||||
sponsorUserType: string;
|
||||
sponsorAgentLevel: number | null;
|
||||
registeredPlayerId: string | null;
|
||||
registeredPlayerUsername: string | null;
|
||||
}
|
||||
|
||||
interface SponsorOption {
|
||||
id: string;
|
||||
username: string;
|
||||
userType: string;
|
||||
agentLevel: number | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
embedded?: boolean;
|
||||
refreshKey?: number;
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
embedded: false,
|
||||
refreshKey: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const items = ref<InviteHistoryRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(props.compact ? 10 : 20);
|
||||
const loading = ref(false);
|
||||
const filterStatus = ref('');
|
||||
const filterSponsorId = ref('');
|
||||
const filterKeyword = ref('');
|
||||
const sponsorOptions = ref<SponsorOption[]>([]);
|
||||
|
||||
const showSponsorFilter = computed(() => auth.isAdmin.value || auth.isAgent.value);
|
||||
const useCompactActions = computed(() => props.embedded || props.compact);
|
||||
|
||||
function isUsed(row: InviteHistoryRow) {
|
||||
return Boolean(row.registeredPlayerId);
|
||||
}
|
||||
|
||||
function displayStatus(row: InviteHistoryRow) {
|
||||
if (row.status === 'ACTIVE') return 'ACTIVE';
|
||||
if (isUsed(row)) return 'USED';
|
||||
return 'REVOKED';
|
||||
}
|
||||
|
||||
function statusLabel(row: InviteHistoryRow) {
|
||||
const status = displayStatus(row);
|
||||
if (status === 'ACTIVE') return t('invite.status.ACTIVE');
|
||||
if (status === 'USED') return t('invite.status.USED');
|
||||
if (status === 'REVOKED') return t('invite.status.REVOKED');
|
||||
return row.status;
|
||||
}
|
||||
|
||||
function statusTagType(row: InviteHistoryRow) {
|
||||
const status = displayStatus(row);
|
||||
if (status === 'ACTIVE') return 'success';
|
||||
if (status === 'USED') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function sponsorLabel(row: InviteHistoryRow) {
|
||||
if (row.sponsorUserType === 'ADMIN') return `${row.sponsorUsername} (${t('role.admin')})`;
|
||||
if (row.sponsorAgentLevel != null && row.sponsorAgentLevel > 0) {
|
||||
return `${row.sponsorUsername} (${t('role.agent_level', { n: row.sponsorAgentLevel })})`;
|
||||
}
|
||||
return row.sponsorUsername;
|
||||
}
|
||||
|
||||
function formatTime(v: string | null) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSponsors() {
|
||||
if (!showSponsorFilter.value) return;
|
||||
try {
|
||||
const { data } = await api.get('/manage/invites/sponsors');
|
||||
sponsorOptions.value = (data.data.items ?? []) as SponsorOption[];
|
||||
} catch {
|
||||
sponsorOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/manage/invites', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: filterStatus.value || undefined,
|
||||
sponsorId: filterSponsorId.value || undefined,
|
||||
keyword: filterKeyword.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
items.value = (data.data.items ?? []) as InviteHistoryRow[];
|
||||
total.value = data.data.total ?? 0;
|
||||
} catch {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
ElMessage.error(t('invite.history_load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
async function copyCode(code: string) {
|
||||
const ok = await copyText(code);
|
||||
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_code_ok') : t('invite.copy_failed'));
|
||||
}
|
||||
|
||||
async function copyLink(code: string) {
|
||||
const ok = await copyText(buildPlayerRegisterUrl(code));
|
||||
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_link_ok') : t('invite.copy_failed'));
|
||||
}
|
||||
|
||||
function canCopy(row: InviteHistoryRow) {
|
||||
return !isUsed(row);
|
||||
}
|
||||
|
||||
function canCopyLink(row: InviteHistoryRow) {
|
||||
return row.status === 'ACTIVE' && !isUsed(row);
|
||||
}
|
||||
|
||||
function canRevoke(row: InviteHistoryRow) {
|
||||
if (row.status !== 'ACTIVE') return false;
|
||||
if (auth.isAdmin.value) return true;
|
||||
return row.sponsorId === auth.user.value?.id;
|
||||
}
|
||||
|
||||
function canDelete(row: InviteHistoryRow) {
|
||||
if (row.status !== 'REVOKED') return false;
|
||||
if (row.registeredPlayerId) return false;
|
||||
if (auth.isAdmin.value) return true;
|
||||
return row.sponsorId === auth.user.value?.id;
|
||||
}
|
||||
|
||||
function registrantLabel(row: InviteHistoryRow) {
|
||||
return row.registeredPlayerUsername ?? t('invite.not_registered');
|
||||
}
|
||||
|
||||
async function revokeRow(row: InviteHistoryRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('invite.revoke_confirm', { code: row.code }), t('invite.revoke_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/manage/invites/${row.id}/revoke`);
|
||||
ElMessage.success(t('invite.revoke_ok'));
|
||||
load();
|
||||
} catch {
|
||||
ElMessage.error(t('invite.revoke_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRow(row: InviteHistoryRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('invite.delete_confirm', { code: row.code }), t('invite.delete_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/manage/invites/${row.id}`);
|
||||
ElMessage.success(t('invite.delete_ok'));
|
||||
load();
|
||||
} catch {
|
||||
ElMessage.error(t('invite.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSponsors();
|
||||
await load();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.refreshKey,
|
||||
() => {
|
||||
load();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite-history" :class="{ compact, embedded }">
|
||||
<div v-if="!embedded" class="history-head">
|
||||
<span class="history-title">{{ t('invite.history_title') }}</span>
|
||||
<span v-if="!compact" class="history-hint">{{ t('invite.history_hint') }}</span>
|
||||
</div>
|
||||
|
||||
<el-form inline class="history-filters" @submit.prevent="onSearch">
|
||||
<el-form-item :label="t('invite.filter_status')">
|
||||
<el-select v-model="filterStatus" clearable :placeholder="t('common.all')" style="width: 120px">
|
||||
<el-option :label="t('invite.status.ACTIVE')" value="ACTIVE" />
|
||||
<el-option :label="t('invite.status.REVOKED')" value="REVOKED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showSponsorFilter" :label="t('invite.filter_sponsor')">
|
||||
<el-select
|
||||
v-model="filterSponsorId"
|
||||
clearable
|
||||
filterable
|
||||
:placeholder="t('common.all')"
|
||||
style="width: 180px"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in sponsorOptions"
|
||||
:key="opt.id"
|
||||
:label="opt.username"
|
||||
:value="opt.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('invite.filter_code')">
|
||||
<el-input
|
||||
v-model="filterKeyword"
|
||||
clearable
|
||||
:placeholder="t('invite.filter_code_ph')"
|
||||
style="width: 140px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:key="locale"
|
||||
:data="items"
|
||||
stripe
|
||||
size="small"
|
||||
class="history-table"
|
||||
:class="{ 'history-table--embedded': embedded }"
|
||||
>
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column prop="code" :label="t('invite.code')" min-width="96" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<code class="code-cell">{{ row.code }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_status')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_sponsor')" min-width="128" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ sponsorLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_registrant')" min-width="96" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'registrant-empty': !row.registeredPlayerUsername }">
|
||||
{{ registrantLabel(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_cashback_rate')" width="88" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.cashbackRate != null">{{ formatRatePercent(row.cashbackRate) }}</span>
|
||||
<span v-else class="registrant-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_created')" min-width="128" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('invite.col_revoked')" min-width="128" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatTime(row.revokedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" :min-width="useCompactActions ? 220 : 260" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="action-cell" :class="{ 'action-cell--compact': useCompactActions }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canCopy(row)"
|
||||
@click="copyCode(row.code)"
|
||||
>
|
||||
{{ useCompactActions ? t('invite.copy_code_short') : t('invite.copy_code') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canCopyLink(row)"
|
||||
@click="copyLink(row.code)"
|
||||
>
|
||||
{{ useCompactActions ? t('invite.copy_link_short') : t('invite.copy_link') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canRevoke(row)"
|
||||
@click="revokeRow(row)"
|
||||
>
|
||||
{{ t('invite.revoke_btn') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canDelete(row)"
|
||||
@click="deleteRow(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
small
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite-history {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.invite-history.compact {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.invite-history.embedded {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.history-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.history-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-filters :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-table--embedded {
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
.history-table :deep(.cell) {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px 8px;
|
||||
}
|
||||
|
||||
.action-cell :deep(.el-button) {
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.action-cell--compact {
|
||||
gap: 2px 6px;
|
||||
}
|
||||
|
||||
.action-cell--compact :deep(.el-button) {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.action-cell :deep(.el-button.is-link) {
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.action-cell--compact :deep(.el-button.is-link) {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.code-cell {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.registrant-empty {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
197
apps/admin/src/components/InviteManageDialog.vue
Normal file
197
apps/admin/src/components/InviteManageDialog.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import InviteCodePanel from './InviteCodePanel.vue';
|
||||
import InviteHistoryPanel from './InviteHistoryPanel.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const activeTab = ref<'generate' | 'history'>('generate');
|
||||
const historyRefreshKey = ref(0);
|
||||
const tabsBootKey = ref(0);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
activeTab.value = 'generate';
|
||||
tabsBootKey.value += 1;
|
||||
await nextTick();
|
||||
historyRefreshKey.value += 1;
|
||||
},
|
||||
);
|
||||
|
||||
function onGenerated() {
|
||||
historyRefreshKey.value += 1;
|
||||
}
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
if (name === 'history') {
|
||||
historyRefreshKey.value += 1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="t('invite.dialog_title')"
|
||||
width="1040px"
|
||||
align-center
|
||||
class="invite-manage-dialog"
|
||||
modal-class="invite-manage-overlay"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<el-tabs
|
||||
:key="tabsBootKey"
|
||||
v-model="activeTab"
|
||||
class="invite-manage-tabs"
|
||||
@tab-change="onTabChange"
|
||||
>
|
||||
<el-tab-pane :label="t('invite.tab_generate')" name="generate">
|
||||
<div class="tab-pane-scroll tab-pane-scroll--generate">
|
||||
<div class="generate-tab-inner">
|
||||
<p class="tab-hint">{{ t('invite.hint') }}</p>
|
||||
<InviteCodePanel require-generate embedded hide-history @generated="onGenerated" />
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
.tab-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.invite-manage-overlay .el-overlay-dialog {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.invite-manage-overlay .invite-manage-dialog.el-dialog {
|
||||
width: 1040px !important;
|
||||
height: min(640px, 90vh);
|
||||
max-width: min(1040px, 96vw);
|
||||
margin: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .el-dialog__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .el-dialog__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 20px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .invite-manage-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .invite-manage-tabs > .el-tabs__header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .invite-manage-tabs > .el-tabs__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .invite-manage-tabs .el-tab-pane {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .tab-pane-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .tab-pane-scroll--generate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 12px 8px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner .tab-hint {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner :deep(.invite-actions) {
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner :deep(.invite-actions .el-button) {
|
||||
min-width: 168px;
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
padding: 8px 22px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner :deep(.invite-body) {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-manage-dialog .generate-tab-inner :deep(.invite-cashback-field) {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -318,13 +318,13 @@ const oldUrl = ref(props.modelValue);
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: rgba(47, 181, 106, 0.4);
|
||||
background: rgba(47, 181, 106, 0.04);
|
||||
border-color: rgba(212, 175, 55, 0.4);
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
}
|
||||
|
||||
.drop-zone.is-dragging {
|
||||
border-color: #2fb56a;
|
||||
background: rgba(47, 181, 106, 0.1);
|
||||
border-color: var(--gold-mid);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.drop-preview {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from './AdminTableEmpty.vue';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import { walletTxTypeKey } from '../utils/walletTx';
|
||||
import { walletDepositMethodLabel, walletTxTypeKey } from '../utils/walletTx';
|
||||
|
||||
interface WalletTxRow {
|
||||
id: string;
|
||||
@@ -17,6 +17,8 @@ interface WalletTxRow {
|
||||
frozenBefore: string;
|
||||
frozenAfter: string;
|
||||
betNo: string | null;
|
||||
depositMethodKey: string | null;
|
||||
depositMethodName: string | null;
|
||||
operatorUsername: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
@@ -62,6 +64,10 @@ function walletTypeLabel(type: string) {
|
||||
return key ? t(key) : type;
|
||||
}
|
||||
|
||||
function depositMethodLabel(row: WalletTxRow) {
|
||||
return walletDepositMethodLabel(row, t);
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
@@ -133,7 +139,7 @@ watch(
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="980px"
|
||||
width="1080px"
|
||||
destroy-on-close
|
||||
class="player-wallet-ledger-dialog"
|
||||
append-to-body
|
||||
@@ -172,9 +178,12 @@ watch(
|
||||
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.transactionId }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_type')" min-width="88">
|
||||
<el-table-column :label="t('finance.col.tx_type')" min-width="80">
|
||||
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('finance.col.deposit_method')" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ depositMethodLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('finance.col.balance_change')" min-width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
|
||||
33
apps/admin/src/components/RatePercentInput.vue
Normal file
33
apps/admin/src/components/RatePercentInput.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number>({ default: 0 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rate-percent-input">
|
||||
<el-input-number
|
||||
v-model="model"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="rate-percent-suffix">%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rate-percent-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rate-percent-suffix {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -109,7 +109,7 @@ function limitLabel(value: string | null) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.transfer-context-alert { margin-bottom: 0; }
|
||||
.c-green { color: #67c23a; font-weight: 600; }
|
||||
.c-green { color: var(--gold-text); font-weight: 600; }
|
||||
.c-amber { color: #e6a23c; font-weight: 600; }
|
||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||
.field-hint { font-size: 12px; color: #666; }
|
||||
|
||||
Reference in New Issue
Block a user