feat: 开户备注、账单展示优化与后台代理管理增强

- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示

- 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分

- 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端

- 后台代理/玩家表格操作栏重构,充值订单增加备注列

- 前台个人中心、充值、账单与验证码组件体验优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:23:58 +08:00
parent 10485ecfaf
commit 03e72ca9b2
46 changed files with 3721 additions and 1059 deletions

View File

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

View File

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