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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:20:11 +08:00

248 lines
6.8 KiB
Vue

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