feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user