feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View 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>

View 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>

View File

@@ -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;
}
/* 登录页:与玩家端金色主题一致 */

View 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>

View 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>

View 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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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">

View 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>

View File

@@ -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; }