feat: 开户备注、账单展示优化与后台代理管理增强
- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示 - 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分 - 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端 - 后台代理/玩家表格操作栏重构,充值订单增加备注列 - 前台个人中心、充值、账单与验证码组件体验优化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,252 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { ref, onMounted } 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 slideRef = ref<SlideVerifyInstance>();
|
||||
const input = ref('');
|
||||
const code = ref('');
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const honeypot = ref('');
|
||||
const validated = ref(false);
|
||||
const showPopup = ref(false);
|
||||
const errorMsg = ref('');
|
||||
|
||||
function openPopup() {
|
||||
if (validated.value) return;
|
||||
showPopup.value = true;
|
||||
errorMsg.value = '';
|
||||
// Refresh captcha after popup renders
|
||||
nextTick(() => {
|
||||
slideRef.value?.refresh();
|
||||
});
|
||||
}
|
||||
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
function closePopup() {
|
||||
showPopup.value = false;
|
||||
}
|
||||
|
||||
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 (!validated.value) {
|
||||
errorMsg.value = t('auth.captcha_wrong');
|
||||
function generateCode() {
|
||||
let result = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result += CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||
}
|
||||
code.value = result;
|
||||
}
|
||||
|
||||
function drawCaptcha() {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const w = 108, h = 44;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = '#2a2210';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < 28; i++) {
|
||||
ctx.fillStyle = `rgba(212, 175, 55, ${0.1 + Math.random() * 0.2})`;
|
||||
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(212, 175, 55, ${0.15 + Math.random() * 0.25})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.random() * w, Math.random() * h);
|
||||
ctx.lineTo(Math.random() * w, Math.random() * h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const charWidth = w / (code.value.length + 1);
|
||||
for (let i = 0; i < code.value.length; i++) {
|
||||
ctx.save();
|
||||
const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 4;
|
||||
const y = h / 2 + (Math.random() - 0.5) * 6;
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate((Math.random() - 0.5) * 0.4);
|
||||
ctx.font = `bold ${18 + Math.random() * 6}px 'Courier New', monospace`;
|
||||
ctx.fillStyle = `hsl(${40 + Math.random() * 20}, ${80 + Math.random() * 20}%, ${65 + Math.random() * 20}%)`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(code.value[i], 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
return validated.value;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
generateCode();
|
||||
input.value = '';
|
||||
validated.value = false;
|
||||
errorMsg.value = '';
|
||||
drawCaptcha();
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (honeypot.value) { refresh(); return false; }
|
||||
if (!input.value.trim()) {
|
||||
errorMsg.value = t('auth.captcha_wrong');
|
||||
return false;
|
||||
}
|
||||
if (input.value.trim().toUpperCase() !== code.value.toUpperCase()) {
|
||||
errorMsg.value = t('auth.captcha_wrong');
|
||||
refresh();
|
||||
return false;
|
||||
}
|
||||
validated.value = true;
|
||||
errorMsg.value = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
defineExpose({ validate, refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 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" 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>
|
||||
<p v-if="errorMsg" class="captcha-error">{{ errorMsg }}</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.captcha-trigger-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-trigger {
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-error {
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.captcha-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.captcha-input:focus {
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.captcha-canvas {
|
||||
flex-shrink: 0;
|
||||
width: 108px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 0 8px 8px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.captcha-error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -50,25 +50,25 @@ const stats = computed(() => {
|
||||
<div class="wallet-stats-panel">
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
|
||||
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
|
||||
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
</div>
|
||||
<div class="stats-divider" />
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
||||
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
|
||||
{{ formatMoneyCompact(stats.net, locale) }}
|
||||
</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
|
||||
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
||||
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,9 +79,8 @@ const stats = computed(() => {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
@@ -93,19 +92,22 @@ const stats = computed(() => {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -116,12 +118,9 @@ const stats = computed(() => {
|
||||
.stat-val.expense { color: #e05050; }
|
||||
.stat-val.cashback { color: #f0b90b; }
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 3px;
|
||||
.stats-divider {
|
||||
height: 1px;
|
||||
margin: 6px 8px;
|
||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user