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

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatMoney } from '../utils/localeDisplay';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { locale, t } = useI18n();
const { profileRaw } = usePlayerProfile();
const router = useRouter();
const open = ref(false);
const root = ref<HTMLElement | null>(null);
@@ -43,6 +45,11 @@ function close() {
open.value = false;
}
function goRecharge() {
close();
router.push('/wallet/recharge');
}
function onOutsideClick(e: Event) {
if (!root.value?.contains(e.target as Node)) open.value = false;
}
@@ -82,6 +89,10 @@ onUnmounted(() => {
<span>{{ t('wallet.available') }}</span>
<span>{{ available }}</span>
</div>
<div class="panel-divider" />
<button type="button" class="panel-recharge-btn" @click="goRecharge">
{{ t('recharge.title') }}
</button>
</div>
<div v-if="open" class="backdrop" @click="close" />
@@ -121,6 +132,9 @@ onUnmounted(() => {
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.15;
}
@@ -185,6 +199,24 @@ onUnmounted(() => {
margin: 4px 0;
}
.panel-recharge-btn {
width: 100%;
padding: 8px 0;
margin-top: 4px;
border-radius: 6px;
border: 1px solid var(--primary, #c8a84e);
background: rgba(200, 168, 78, 0.12);
color: var(--primary-light, #c8a84e);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.panel-recharge-btn:active {
background: rgba(200, 168, 78, 0.24);
}
.backdrop {
position: fixed;
inset: 0;

View File

@@ -48,10 +48,7 @@ onUnmounted(() => {
<div ref="root" class="locale-switch" :class="{ compact, open }">
<button type="button" class="locale-trigger" :aria-expanded="open" aria-haspopup="listbox" @click.stop="toggle">
<LocaleFlag :locale="locale" :size="compact ? 16 : 18" />
<span class="locale-label">{{ currentLabel }}</span>
<span class="locale-chevron" aria-hidden="true"></span>
</button>
<ul v-show="open" class="locale-menu" role="listbox" :aria-label="compact ? 'Language' : undefined">
</button> <ul v-show="open" class="locale-menu" role="listbox" :aria-label="compact ? 'Language' : undefined">
<li
v-for="l in locales"
:key="l.code"
@@ -72,14 +69,16 @@ onUnmounted(() => {
.locale-switch {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.locale-trigger {
display: inline-flex;
align-items: center;
gap: 5px;
justify-content: center;
width: 36px;
height: 36px;
padding: 0 8px 0 6px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
@@ -89,29 +88,12 @@ onUnmounted(() => {
font-family: inherit;
cursor: pointer;
box-sizing: border-box;
flex-shrink: 0;
}
.locale-switch.compact .locale-trigger {
height: auto;
min-height: 30px;
padding: 4px 6px 4px 5px;
}
.locale-label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.locale-chevron {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.locale-switch.open .locale-chevron {
transform: rotate(180deg);
width: 30px;
height: 30px;
}
.locale-menu {

View File

@@ -1,150 +1,255 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import SlideVerify, { type SlideVerifyInstance } from 'vue3-slide-verify';
import 'vue3-slide-verify/dist/style.css';
const { t } = useI18n();
const input = ref('');
const code = ref('');
const canvasRef = ref<HTMLCanvasElement | null>(null);
const honeypot = ref('');
const slideRef = ref<SlideVerifyInstance>();
const validated = ref(false);
const showPopup = ref(false);
const errorMsg = ref('');
function generateCode() {
code.value = String(Math.floor(1000 + Math.random() * 9000));
function openPopup() {
if (validated.value) return;
showPopup.value = true;
errorMsg.value = '';
// Refresh captcha after popup renders
nextTick(() => {
slideRef.value?.refresh();
});
}
function drawCaptcha() {
const canvas = canvasRef.value;
if (!canvas) return;
const w = 108;
const h = 44;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#7c3aed';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 28; i++) {
ctx.fillStyle = `rgba(255,255,255,${0.15 + Math.random() * 0.35})`;
ctx.beginPath();
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
ctx.fill();
}
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(255,255,255,${0.2 + Math.random() * 0.3})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * w, Math.random() * h);
ctx.lineTo(Math.random() * w, Math.random() * h);
ctx.stroke();
}
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate((Math.random() - 0.5) * 0.12);
ctx.font = 'italic bold 26px Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(code.value, 0, 1);
ctx.restore();
function closePopup() {
showPopup.value = false;
}
function refresh() {
generateCode();
input.value = '';
drawCaptcha();
function onSuccess() {
validated.value = true;
errorMsg.value = '';
showPopup.value = false;
}
function onFail() {
validated.value = false;
}
function onAgain() {
validated.value = false;
slideRef.value?.refresh();
}
function validate(): boolean {
if (honeypot.value) {
refresh();
return false;
if (!validated.value) {
errorMsg.value = t('auth.captcha_wrong');
}
return input.value.trim() === code.value;
return validated.value;
}
onMounted(refresh);
function refresh() {
validated.value = false;
errorMsg.value = '';
}
defineExpose({ validate, refresh });
</script>
<template>
<div class="captcha-row">
<input
v-model="honeypot"
type="text"
name="website"
tabindex="-1"
autocomplete="off"
class="hp-field"
aria-hidden="true"
/>
<input
v-model="input"
type="text"
inputmode="numeric"
maxlength="4"
class="captcha-input"
:placeholder="t('auth.captcha_placeholder')"
autocomplete="off"
/>
<canvas
ref="canvasRef"
class="captcha-canvas"
:title="t('auth.captcha_refresh')"
role="button"
tabindex="0"
@click="refresh"
@keydown.enter="refresh"
/>
<div class="captcha-trigger-wrapper">
<!-- Trigger row -->
<div class="captcha-trigger" :class="{ verified: validated }" @click="openPopup">
<span v-if="validated" class="captcha-success-icon"></span>
<span class="captcha-trigger-text">
{{ validated ? t('auth.verified') : t('auth.click_to_verify') }}
</span>
</div>
<p v-if="errorMsg && !validated" class="slide-error">{{ errorMsg }}</p>
<!-- Popup overlay -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showPopup" class="captcha-overlay" @click.self="closePopup">
<div class="captcha-popup">
<div class="captcha-popup-header">
<span>{{ t('auth.slide_to_verify') }}</span>
<button class="captcha-popup-close" @click="closePopup"></button>
</div>
<div class="captcha-popup-body">
<SlideVerify
ref="slideRef"
:w="300"
:h="150"
:l="42"
:r="9"
:accuracy="3"
:slider-text="t('auth.slide_to_verify')"
@success="onSuccess"
@fail="onFail"
@again="onAgain"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.captcha-row {
.captcha-trigger-wrapper {
width: 100%;
}
.captcha-trigger {
display: flex;
gap: 0;
align-items: stretch;
height: 44px;
}
.hp-field {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.captcha-input {
flex: 1;
min-width: 0;
padding: 0 14px;
border: none;
border-radius: 8px 0 0 8px;
background: #ffffff;
color: #111;
font-size: 15px;
font-weight: 500;
outline: none;
}
.captcha-input::placeholder {
color: #9ca3af;
}
.captcha-canvas {
flex-shrink: 0;
width: 108px;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border-radius: 8px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
display: block;
border-radius: 0 8px 8px 0;
transition: border-color 0.2s;
user-select: none;
}
.captcha-trigger:active {
border-color: var(--primary, #c8a84e);
}
.captcha-trigger.verified {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.08);
cursor: default;
}
.captcha-success-icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.captcha-trigger-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.captcha-trigger.verified .captcha-trigger-text {
color: #22c55e;
}
/* Popup */
.captcha-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.captcha-popup {
background: #1e1e1e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 340px;
width: calc(100vw - 40px);
}
.captcha-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.captcha-popup-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 16px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.captcha-popup-close:active {
color: rgba(255, 255, 255, 0.8);
}
.captcha-popup-body {
padding: 16px;
display: flex;
justify-content: center;
}
.captcha-popup-body :deep(.slide-verify) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-slider) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-info) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.6);
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__text) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__handler) {
background-color: var(--primary, #c8a84e);
}
.captcha-popup-body :deep(.slide-verify-slider__bg-fill) {
background-color: rgba(200, 168, 78, 0.25);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--success) {
background-color: rgba(34, 197, 94, 0.2);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--fail) {
background-color: rgba(239, 68, 68, 0.2);
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-error {
color: var(--danger);
font-size: 12px;
font-weight: 600;
margin: 4px 0 0;
}
</style>

View File

@@ -45,6 +45,11 @@ function goEdit() {
router.push('/profile/edit');
}
function goRecharge() {
close();
router.push('/wallet/recharge');
}
function logout() {
close();
auth.logout();
@@ -60,6 +65,7 @@ function logout() {
<div v-if="open" class="avatar-menu">
<div class="menu-user">{{ auth.user?.username }}</div>
<button type="button" class="menu-item recharge" @click="goRecharge">{{ t('recharge.title') }}</button>
<button type="button" class="menu-item" @click="goEdit">{{ t('profile.edit') }}</button>
<button type="button" class="menu-item danger" @click="logout">{{ t('auth.logout') }}</button>
</div>
@@ -139,6 +145,11 @@ function logout() {
background: rgba(255, 255, 255, 0.04);
}
.menu-item.recharge {
color: var(--primary-light);
font-weight: 700;
}
.menu-item.danger {
color: var(--danger);
}