feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,10 +11,12 @@
|
||||
"dependencies": {
|
||||
"@thebet365/shared": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-slide-verify": "^1.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ api.interceptors.response.use(
|
||||
if (err.response?.status === 401) {
|
||||
const url: string = err.config?.url ?? '';
|
||||
// Don't redirect on login/auth failures — let the caller handle the error
|
||||
if (!url.includes('/auth/login')) {
|
||||
if (!url.includes('/auth/login') && !url.includes('/auth/register')) {
|
||||
localStorage.removeItem('token');
|
||||
// 不再强制跳转登录页,让调用方处理 401
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ watch(
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-actions :deep(.locale-switch:not(.compact)),
|
||||
.header-actions :deep(.cash-chip),
|
||||
@@ -213,6 +214,8 @@ watch(
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-btn:active {
|
||||
|
||||
@@ -70,16 +70,26 @@ const i18n = createI18n({
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
register: '注册账号',
|
||||
logout: '退出登录',
|
||||
username: '账号',
|
||||
password: '密码',
|
||||
invite_code: '邀请码',
|
||||
optional: '选填',
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: '点击换一张',
|
||||
captcha_wrong: '验证码错误',
|
||||
captcha_wrong: '请完成滑块验证',
|
||||
slide_to_verify: '向右滑动完成验证',
|
||||
click_to_verify: '点击验证',
|
||||
verified: '验证成功',
|
||||
login_required: '请先登录',
|
||||
login_hint: '登录后可下注及访问更多功能',
|
||||
go_login: '去登录',
|
||||
continue_browsing: '暂不登录,继续浏览',
|
||||
go_register: '没有账号?立即注册',
|
||||
have_account: '已有账号?去登录',
|
||||
register_btn: '注册',
|
||||
register_failed: '注册失败,请重试',
|
||||
continue_browsing: '暂不登录',
|
||||
username_placeholder: '请输入账号',
|
||||
password_placeholder: '请输入密码',
|
||||
login_btn: '登录',
|
||||
@@ -99,7 +109,7 @@ const i18n = createI18n({
|
||||
unsettled: '未结算',
|
||||
available: '可用',
|
||||
no_records: '暂无账单记录',
|
||||
tx_deposit: '人工存款',
|
||||
tx_deposit: '充值',
|
||||
tx_withdraw: '人工提款',
|
||||
tx_adjust: '人工调整',
|
||||
tx_bet_freeze: '投注冻结',
|
||||
@@ -116,7 +126,7 @@ const i18n = createI18n({
|
||||
stats_net: '净额',
|
||||
stats_cashback: '反水',
|
||||
filter_all: '全部',
|
||||
filter_deposit: '存款',
|
||||
filter_deposit: '充值',
|
||||
filter_withdraw: '提款',
|
||||
filter_bet: '投注',
|
||||
filter_cashback: '反水',
|
||||
@@ -135,7 +145,7 @@ const i18n = createI18n({
|
||||
detail_tx_id: '流水号',
|
||||
detail_not_found: '账单不存在',
|
||||
ref_bet: '投注',
|
||||
ref_deposit: '存款',
|
||||
ref_deposit: '充值',
|
||||
ref_withdraw: '提款',
|
||||
view_cashbacks: '返水明细',
|
||||
view_cashbacks_detail: '查看返水周期明细',
|
||||
@@ -143,6 +153,42 @@ const i18n = createI18n({
|
||||
detail_cashback_link: '查看返水明细',
|
||||
ref_cashback: '返水批次',
|
||||
},
|
||||
recharge: {
|
||||
title: '充值',
|
||||
history: '记录',
|
||||
history_title: '充值记录',
|
||||
bank_transfer: '银行转账',
|
||||
bank_name: '银行名称',
|
||||
account_holder: '账户名',
|
||||
account_number: '账号',
|
||||
usdt_address: 'USDT 地址',
|
||||
amount_label: '充值金额',
|
||||
amount_placeholder: '请输入充值金额',
|
||||
screenshot_label: '上传转账截图',
|
||||
upload_hint: '点击上传截图(最大 5MB)',
|
||||
compressing: '压缩中',
|
||||
submit: '提交充值',
|
||||
submitting: '提交中',
|
||||
submitted: '充值已提交',
|
||||
pending_review: '管理员正在审核,请耐心等待',
|
||||
new_recharge: '继续充值',
|
||||
no_methods: '暂无可用充值方式',
|
||||
select_method: '请选择充值方式',
|
||||
enter_amount: '请输入充值金额',
|
||||
upload_screenshot: '请上传转账截图',
|
||||
submit_failed: '提交失败,请重试',
|
||||
file_must_be_image: '请上传图片文件',
|
||||
file_too_large: '文件不能超过 10MB',
|
||||
status_pending: '审核中',
|
||||
status_approved: '已通过',
|
||||
status_rejected: '已拒绝',
|
||||
no_orders: '暂无充值记录',
|
||||
credited: '实际到账',
|
||||
reject_reason: '拒绝原因',
|
||||
apply_time: '申请时间',
|
||||
review_time: '审核时间',
|
||||
remark: '审核备注',
|
||||
},
|
||||
cashback: {
|
||||
title: '返水明细',
|
||||
list_title: '发放明细',
|
||||
@@ -398,16 +444,26 @@ const i18n = createI18n({
|
||||
},
|
||||
auth:
|
||||
{ login: 'Login',
|
||||
register: 'Create Account',
|
||||
logout: 'Log out',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
invite_code: 'Invitation Code',
|
||||
optional: 'Optional',
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: 'Click to refresh',
|
||||
captcha_wrong: 'Invalid captcha',
|
||||
captcha_wrong: 'Please complete the slider verification',
|
||||
slide_to_verify: 'Slide to verify',
|
||||
click_to_verify: 'Click to verify',
|
||||
verified: 'Verified',
|
||||
login_required: 'Login Required',
|
||||
login_hint: 'Log in to place bets and access more features',
|
||||
go_login: 'Go to login',
|
||||
continue_browsing: 'Continue browsing',
|
||||
go_register: 'No account? Register now',
|
||||
have_account: 'Already have an account? Log in',
|
||||
register_btn: 'Register',
|
||||
register_failed: 'Registration failed, please try again',
|
||||
continue_browsing: 'Skip login',
|
||||
username_placeholder: 'Enter username',
|
||||
password_placeholder: 'Enter password',
|
||||
login_btn: 'Log In',
|
||||
@@ -471,6 +527,42 @@ const i18n = createI18n({
|
||||
ref_cashback: 'Cashback batch',
|
||||
detail_cashback_link: 'View cashback details',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Recharge',
|
||||
history: 'History',
|
||||
history_title: 'Recharge History',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
bank_name: 'Bank Name',
|
||||
account_holder: 'Account Holder',
|
||||
account_number: 'Account Number',
|
||||
usdt_address: 'USDT Address',
|
||||
amount_label: 'Amount',
|
||||
amount_placeholder: 'Enter recharge amount',
|
||||
screenshot_label: 'Upload Screenshot',
|
||||
upload_hint: 'Click to upload screenshot (max 5MB)',
|
||||
compressing: 'Compressing',
|
||||
submit: 'Submit',
|
||||
submitting: 'Submitting',
|
||||
submitted: 'Recharge Submitted',
|
||||
pending_review: 'Admin is reviewing, please wait',
|
||||
new_recharge: 'New Recharge',
|
||||
no_methods: 'No payment methods available',
|
||||
select_method: 'Please select a payment method',
|
||||
enter_amount: 'Please enter the amount',
|
||||
upload_screenshot: 'Please upload a screenshot',
|
||||
submit_failed: 'Submit failed, please retry',
|
||||
file_must_be_image: 'Please upload an image file',
|
||||
file_too_large: 'File exceeds 10MB',
|
||||
status_pending: 'Pending',
|
||||
status_approved: 'Approved',
|
||||
status_rejected: 'Rejected',
|
||||
no_orders: 'No recharge records',
|
||||
credited: 'Credited',
|
||||
reject_reason: 'Rejection reason',
|
||||
apply_time: 'Apply time',
|
||||
review_time: 'Review time',
|
||||
remark: 'Remark',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Cashback Details',
|
||||
list_title: 'Payout details',
|
||||
@@ -732,16 +824,26 @@ const i18n = createI18n({
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
register: 'Daftar Akaun',
|
||||
logout: 'Log Keluar',
|
||||
username: 'Nama Pengguna',
|
||||
password: 'Kata Laluan',
|
||||
invite_code: 'Kod Jemputan',
|
||||
optional: 'Pilihan',
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: 'Klik untuk muat semula',
|
||||
captcha_wrong: 'Kod pengesahan salah',
|
||||
captcha_wrong: 'Sila lengkapkan pengesahan gelongsor',
|
||||
slide_to_verify: 'Gelongsor untuk mengesahkan',
|
||||
click_to_verify: 'Klik untuk mengesahkan',
|
||||
verified: 'Disahkan',
|
||||
login_required: 'Sila Log Masuk',
|
||||
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
|
||||
go_login: 'Pergi log masuk',
|
||||
continue_browsing: 'Teruskan melayari',
|
||||
go_register: 'Tiada akaun? Daftar sekarang',
|
||||
have_account: 'Sudah ada akaun? Log masuk',
|
||||
register_btn: 'Daftar',
|
||||
register_failed: 'Pendaftaran gagal, sila cuba lagi',
|
||||
continue_browsing: 'Langkau log masuk',
|
||||
username_placeholder: 'Masukkan nama pengguna',
|
||||
password_placeholder: 'Masukkan kata laluan',
|
||||
login_btn: 'Log Masuk',
|
||||
@@ -805,6 +907,42 @@ const i18n = createI18n({
|
||||
ref_cashback: 'Batch rebat',
|
||||
detail_cashback_link: 'Lihat butiran rebat',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Topup',
|
||||
history: 'Sejarah',
|
||||
history_title: 'Sejarah Topup',
|
||||
bank_transfer: 'Pindahan Bank',
|
||||
bank_name: 'Nama Bank',
|
||||
account_holder: 'Pemegang Akaun',
|
||||
account_number: 'Nombor Akaun',
|
||||
usdt_address: 'Alamat USDT',
|
||||
amount_label: 'Jumlah',
|
||||
amount_placeholder: 'Masukkan jumlah topup',
|
||||
screenshot_label: 'Muat Naik Screenshot',
|
||||
upload_hint: 'Klik untuk muat naik (maks 5MB)',
|
||||
compressing: 'Memampat',
|
||||
submit: 'Hantar',
|
||||
submitting: 'Menghantar',
|
||||
submitted: 'Topup Dihantar',
|
||||
pending_review: 'Admin sedang menyemak, sila tunggu',
|
||||
new_recharge: 'Topup Baru',
|
||||
no_methods: 'Tiada kaedah pembayaran tersedia',
|
||||
select_method: 'Sila pilih kaedah pembayaran',
|
||||
enter_amount: 'Sila masukkan jumlah',
|
||||
upload_screenshot: 'Sila muat naik screenshot',
|
||||
submit_failed: 'Gagal, sila cuba lagi',
|
||||
file_must_be_image: 'Sila muat naik fail imej',
|
||||
file_too_large: 'Fail melebihi 10MB',
|
||||
status_pending: 'Menunggu',
|
||||
status_approved: 'Diluluskan',
|
||||
status_rejected: 'Ditolak',
|
||||
no_orders: 'Tiada rekod topup',
|
||||
credited: 'Dikreditkan',
|
||||
reject_reason: 'Sebab penolakan',
|
||||
apply_time: 'Masa permohonan',
|
||||
review_time: 'Masa semakan',
|
||||
remark: 'Catatan',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Butiran Rebat',
|
||||
list_title: 'Butiran pembayaran',
|
||||
|
||||
@@ -5,6 +5,7 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
||||
{ path: '/register', component: () => import('../views/RegisterView.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
@@ -20,6 +21,8 @@ const router = createRouter({
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/recharge', component: () => import('../views/RechargeView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/recharge/history', component: () => import('../views/RechargeHistoryView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
@@ -31,7 +34,7 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.path === '/login' && auth.token) return '/';
|
||||
if ((to.path === '/login' || to.path === '/register') && auth.token) return '/';
|
||||
// 需要登录的页面 — 未登录时弹出登录提示,留在当前页
|
||||
if (to.meta.requiresAuth && !auth.token) {
|
||||
auth.showLoginPrompt(to.fullPath);
|
||||
|
||||
@@ -32,6 +32,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return returnTo;
|
||||
}
|
||||
|
||||
async function register(username: string, password: string, inviteCode?: string) {
|
||||
const locale = localStorage.getItem('locale') || 'zh-CN';
|
||||
const code = inviteCode?.trim();
|
||||
const { data } = await api.post('/player/auth/register', {
|
||||
username,
|
||||
password,
|
||||
locale,
|
||||
...(code ? { inviteCode: code } : {}),
|
||||
});
|
||||
token.value = data.data.token;
|
||||
user.value = data.data.user;
|
||||
localStorage.setItem('token', token.value);
|
||||
localStorage.setItem('user', JSON.stringify(user.value));
|
||||
loginReturnTo.value = '';
|
||||
loginPromptVisible.value = false;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = '';
|
||||
user.value = null;
|
||||
@@ -40,7 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
token, user, login, logout,
|
||||
token, user, login, register, logout,
|
||||
loginPromptVisible, loginReturnTo,
|
||||
showLoginPrompt, hideLoginPrompt,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export const TX_KEY_MAP: Record<string, string> = {
|
||||
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
||||
DEPOSIT: 'wallet.tx_deposit',
|
||||
WITHDRAW: 'wallet.tx_withdraw',
|
||||
PLAYER_DEPOSIT: 'wallet.tx_deposit',
|
||||
};
|
||||
|
||||
export function txTypeKey(type: string): string {
|
||||
|
||||
@@ -51,6 +51,13 @@ function continueBrowsing() {
|
||||
const target = isGuestBrowsablePath(redirect) ? redirect || '/' : '/';
|
||||
router.replace(target);
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
router.push({
|
||||
path: '/register',
|
||||
query: route.query.redirect ? { redirect: route.query.redirect as string } : {},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,10 +75,13 @@ function continueBrowsing() {
|
||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||
{{ t('auth.login') }}
|
||||
</button>
|
||||
<button type="button" class="btn-skip" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
<button type="button" class="btn-skip" @click="goRegister">
|
||||
{{ t('auth.go_register') }}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,6 +154,25 @@ label {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.btn-skip-light {
|
||||
position: absolute;
|
||||
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-skip-light:active {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
|
||||
import LocaleFlag from '../components/LocaleFlag.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
@@ -53,7 +53,7 @@ function runCountUp(target: number) {
|
||||
const displayedBalance = computed(() =>
|
||||
animating.value
|
||||
? formatMoney(displayAmount.value, locale.value)
|
||||
: formatMoney(profile.value?.wallet?.availableBalance, locale.value),
|
||||
: formatMoneyCompact(profile.value?.wallet?.availableBalance, locale.value),
|
||||
);
|
||||
|
||||
async function fetchProfile() {
|
||||
@@ -93,7 +93,7 @@ function logout() {
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div class="wallet-banner" @click="router.push('/wallet/detail')">
|
||||
<div class="wallet-banner">
|
||||
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
||||
<img src="/logo.png" alt="TheBet365" class="wallet-card-logo" />
|
||||
<div class="wallet-banner-info">
|
||||
@@ -102,7 +102,12 @@ function logout() {
|
||||
<LocaleFlag :locale="locale" :size="14" />
|
||||
{{ t('wallet.balance') }}
|
||||
</span>
|
||||
<p class="card-balance">{{ displayedBalance }}</p>
|
||||
<div class="card-balance-row">
|
||||
<p class="card-balance">{{ displayedBalance }}</p>
|
||||
<button type="button" class="card-recharge-btn" @click.stop="router.push('/wallet/recharge')">
|
||||
{{ t('recharge.title') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<div class="card-holder">
|
||||
@@ -197,15 +202,9 @@ function logout() {
|
||||
margin-left: -5px;
|
||||
margin-right: -5px;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wallet-banner:active .wallet-banner-img {
|
||||
filter: brightness(0.9);
|
||||
transition: filter 0.1s;
|
||||
}
|
||||
|
||||
.wallet-banner-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -265,8 +264,6 @@ function logout() {
|
||||
|
||||
.card-balance {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
font-size: clamp(22px, 6.8vw, 36px);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -283,6 +280,12 @@ function logout() {
|
||||
drop-shadow(0 0 10px rgba(212, 175, 55, 0.22));
|
||||
}
|
||||
|
||||
.card-balance-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -336,6 +339,30 @@ function logout() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-recharge-btn {
|
||||
z-index: 3;
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
color: #1a1a1a;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.4),
|
||||
0 0 12px rgba(212, 175, 55, 0.25);
|
||||
pointer-events: auto;
|
||||
transition: transform 0.1s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-recharge-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
208
apps/player/src/views/RechargeHistoryView.vue
Normal file
208
apps/player/src/views/RechargeHistoryView.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
interface DepositOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
methodType: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
approvedAmount: string | null;
|
||||
rejectReason: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
reviewedAt: string | null;
|
||||
paymentMethodName: string | null;
|
||||
}
|
||||
|
||||
const items = ref<DepositOrder[]>([]);
|
||||
const loading = ref(true);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/deposit-orders', { params: { page: page.value } });
|
||||
const result = data.data ?? { items: [], total: 0 };
|
||||
items.value = result.items ?? [];
|
||||
total.value = result.total ?? 0;
|
||||
} catch { /* */ } finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: fetchOrders,
|
||||
});
|
||||
|
||||
function statusClass(s: string) {
|
||||
if (s === 'APPROVED') return 'status-approved';
|
||||
if (s === 'REJECTED') return 'status-rejected';
|
||||
return 'status-pending';
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
if (s === 'APPROVED') return t('recharge.status_approved');
|
||||
if (s === 'REJECTED') return t('recharge.status_rejected');
|
||||
return t('recharge.status_pending');
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/wallet');
|
||||
}
|
||||
|
||||
function goRecharge() {
|
||||
router.push('/wallet/recharge');
|
||||
}
|
||||
|
||||
onMounted(fetchOrders);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recharge-history-page">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h2>{{ t('recharge.history_title') }}</h2>
|
||||
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="{ height: `${pullDistance}px`, opacity: Math.min(pullDistance / 48, 1) }"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!items.length" class="empty">{{ t('recharge.no_orders') }}</div>
|
||||
|
||||
<div v-else class="order-list">
|
||||
<div v-for="order in items" :key="order.id" class="order-card" :class="{ rejected: order.status === 'REJECTED' }">
|
||||
<div class="order-header">
|
||||
<span class="method-badge" :class="order.methodType === 'BANK' ? 'bank' : 'usdt'">{{ order.methodType }}</span>
|
||||
<span :class="['status-badge', statusClass(order.status)]">{{ statusLabel(order.status) }}</span>
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<div class="order-amount">{{ formatMoney(order.amount, locale) }}</div>
|
||||
<div v-if="order.approvedAmount && order.approvedAmount !== order.amount" class="approved-amount">
|
||||
{{ t('recharge.credited') }}: {{ formatMoney(order.approvedAmount, locale) }}
|
||||
</div>
|
||||
<div class="order-info-row">
|
||||
<span class="info-label">{{ order.paymentMethodName || '-' }}</span>
|
||||
</div>
|
||||
<div class="order-times">
|
||||
<div class="time-row">
|
||||
<span class="time-label">{{ t('recharge.apply_time') }}</span>
|
||||
<span class="time-value">{{ new Date(order.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="order.reviewedAt" class="time-row">
|
||||
<span class="time-label">{{ t('recharge.review_time') }}</span>
|
||||
<span class="time-value">{{ new Date(order.reviewedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="order.remark" class="order-remark">
|
||||
{{ t('recharge.remark') }}: {{ order.remark }}
|
||||
</div>
|
||||
<div v-if="order.status === 'REJECTED' && order.rejectReason" class="reject-reason">
|
||||
{{ t('recharge.reject_reason') }}: {{ order.rejectReason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recharge-history-page { padding: 0 16px 24px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
|
||||
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
|
||||
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
|
||||
.recharge-btn { background: none; border: none; color: var(--primary-light); font-size: 13px; cursor: pointer; font-weight: 600; }
|
||||
.state { display: flex; justify-content: center; padding: 48px; }
|
||||
.empty { text-align: center; color: #666; padding: 48px 16px; font-weight: 600; }
|
||||
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
|
||||
|
||||
.order-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.order-card {
|
||||
background: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.order-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.6), transparent);
|
||||
}
|
||||
.order-card.rejected {
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #1f1f1f 40%, #161616 100%);
|
||||
border-color: rgba(100, 100, 100, 0.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.order-card.rejected::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(100, 100, 100, 0.4), transparent);
|
||||
}
|
||||
.order-card.rejected .order-amount {
|
||||
background: linear-gradient(135deg, #888, #666);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.order-card.rejected .info-label {
|
||||
color: rgba(150, 150, 150, 0.7);
|
||||
}
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||
.method-badge.bank { background: rgba(30, 58, 95, 0.6); color: #66b1ff; }
|
||||
.method-badge.usdt { background: rgba(26, 58, 42, 0.6); color: #67c23a; }
|
||||
.status-badge { font-size: 12px; font-weight: 700; }
|
||||
.status-pending { color: #e6a23c; }
|
||||
.status-approved { color: #67c23a; }
|
||||
.status-rejected { color: #f56c6c; }
|
||||
.order-body { }
|
||||
.order-amount {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 4px;
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.approved-amount { font-size: 12px; color: #67c23a; margin-bottom: 6px; font-weight: 600; }
|
||||
.order-info-row { margin-bottom: 8px; }
|
||||
.info-label { font-size: 13px; color: rgba(212, 175, 55, 0.7); font-weight: 600; }
|
||||
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
|
||||
.time-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.time-label { font-size: 11px; color: #888; }
|
||||
.time-value { font-size: 11px; color: #aaa; font-variant-numeric: tabular-nums; }
|
||||
.order-remark {
|
||||
font-size: 12px;
|
||||
color: rgba(212, 175, 55, 0.8);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
border-left: 2px solid rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
.reject-reason { margin-top: 8px; font-size: 12px; color: #f56c6c; background: #2a1515; padding: 6px 10px; border-radius: 6px; }
|
||||
</style>
|
||||
399
apps/player/src/views/RechargeView.vue
Normal file
399
apps/player/src/views/RechargeView.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import api from '../api';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
methodType: string;
|
||||
bankName: string | null;
|
||||
accountHolder: string | null;
|
||||
accountNumber: string | null;
|
||||
usdtAddress: string | null;
|
||||
qrCodeUrl: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
const methodType = ref<'BANK' | 'USDT'>('BANK');
|
||||
const methods = ref<PaymentMethod[]>([]);
|
||||
const selectedMethod = ref<PaymentMethod | null>(null);
|
||||
const amount = ref<string>('');
|
||||
const screenshotFile = ref<File | null>(null);
|
||||
const screenshotPreview = ref<string>('');
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
const orderNo = ref('');
|
||||
const compressing = ref(false);
|
||||
|
||||
const bankMethods = computed(() => methods.value.filter((m) => m.methodType === 'BANK'));
|
||||
const usdtMethods = computed(() => methods.value.filter((m) => m.methodType === 'USDT'));
|
||||
const currentMethods = computed(() => methodType.value === 'BANK' ? bankMethods.value : usdtMethods.value);
|
||||
|
||||
async function fetchMethods() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/payment-methods');
|
||||
methods.value = (data.data ?? []).map((m: any) => ({ ...m, id: String(m.id) }));
|
||||
// Auto-select first available
|
||||
if (currentMethods.value.length) {
|
||||
selectedMethod.value = currentMethods.value[0];
|
||||
}
|
||||
} catch { /* */ } finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchType(type: 'BANK' | 'USDT') {
|
||||
methodType.value = type;
|
||||
selectedMethod.value = currentMethods.value.length ? currentMethods.value[0] : null;
|
||||
}
|
||||
|
||||
function selectMethod(m: PaymentMethod) {
|
||||
selectedMethod.value = m;
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert(t('recharge.file_must_be_image'));
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Max 10MB before compression
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(t('recharge.file_too_large'));
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Compress image
|
||||
compressing.value = true;
|
||||
try {
|
||||
const compressed = await imageCompression(file, {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
});
|
||||
screenshotFile.value = compressed as File;
|
||||
screenshotPreview.value = URL.createObjectURL(compressed);
|
||||
} catch {
|
||||
// Fallback: use original if compression fails
|
||||
screenshotFile.value = file;
|
||||
screenshotPreview.value = URL.createObjectURL(file);
|
||||
} finally {
|
||||
compressing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeScreenshot() {
|
||||
screenshotFile.value = null;
|
||||
screenshotPreview.value = '';
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedMethod.value) {
|
||||
alert(t('recharge.select_method'));
|
||||
return;
|
||||
}
|
||||
const amt = parseFloat(amount.value);
|
||||
if (!amt || amt <= 0) {
|
||||
alert(t('recharge.enter_amount'));
|
||||
return;
|
||||
}
|
||||
if (!screenshotFile.value) {
|
||||
alert(t('recharge.upload_screenshot'));
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('paymentMethodId', selectedMethod.value.id);
|
||||
fd.append('amount', String(amt));
|
||||
fd.append('screenshot', screenshotFile.value);
|
||||
|
||||
const { data } = await api.post('/player/deposit-orders', fd);
|
||||
const result = data.data;
|
||||
orderNo.value = result?.orderNo ?? '';
|
||||
success.value = true;
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message || t('recharge.submit_failed'));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goHistory() {
|
||||
router.push('/wallet/recharge/history');
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
success.value = false;
|
||||
amount.value = '';
|
||||
screenshotFile.value = null;
|
||||
screenshotPreview.value = '';
|
||||
orderNo.value = '';
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard?.writeText(text);
|
||||
}
|
||||
|
||||
onMounted(fetchMethods);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recharge-page">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h2>{{ t('recharge.title') }}</h2>
|
||||
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="success" class="success-state">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>{{ t('recharge.submitted') }}</h3>
|
||||
<p class="order-no">{{ orderNo }}</p>
|
||||
<p class="success-hint">{{ t('recharge.pending_review') }}</p>
|
||||
<button class="btn-primary" @click="resetForm">{{ t('recharge.new_recharge') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
:class="['tab', methodType === 'BANK' && 'active']"
|
||||
@click="switchType('BANK')"
|
||||
>{{ t('recharge.bank_transfer') }}</button>
|
||||
<button
|
||||
:class="['tab', methodType === 'USDT' && 'active']"
|
||||
@click="switchType('USDT')"
|
||||
>USDT</button>
|
||||
</div>
|
||||
|
||||
<div v-if="currentMethods.length" class="methods-list">
|
||||
<button
|
||||
v-for="m in currentMethods"
|
||||
:key="m.id"
|
||||
:class="['method-pill', selectedMethod?.id === m.id && 'selected']"
|
||||
@click="selectMethod(m)"
|
||||
>
|
||||
<span class="pill-name">{{ m.displayName || m.bankName || m.usdtAddress }}</span>
|
||||
<span v-if="m.methodType === 'BANK'" class="pill-sub">{{ m.accountHolder }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-methods">{{ t('recharge.no_methods') }}</div>
|
||||
|
||||
<div v-if="selectedMethod" class="method-info">
|
||||
<template v-if="selectedMethod.methodType === 'BANK'">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('recharge.bank_name') }}</span>
|
||||
<span class="info-value">{{ selectedMethod.bankName }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('recharge.account_holder') }}</span>
|
||||
<span class="info-value">{{ selectedMethod.accountHolder }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('recharge.account_number') }}</span>
|
||||
<span class="info-value copyable" @click="copyText(selectedMethod!.accountNumber || '')">
|
||||
{{ selectedMethod.accountNumber }}
|
||||
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('recharge.usdt_address') }}</span>
|
||||
<span class="info-value copyable" @click="copyText(selectedMethod!.usdtAddress || '')">
|
||||
{{ selectedMethod.usdtAddress }}
|
||||
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="selectedMethod.qrCodeUrl" class="qr-container">
|
||||
<img :src="selectedMethod.qrCodeUrl" class="qr-image" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label>{{ t('recharge.amount_label') }}</label>
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:placeholder="t('recharge.amount_placeholder')"
|
||||
class="amount-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label>{{ t('recharge.screenshot_label') }}</label>
|
||||
<div v-if="screenshotPreview" class="screenshot-preview">
|
||||
<img :src="screenshotPreview" />
|
||||
<button class="remove-btn" @click="removeScreenshot">✕</button>
|
||||
</div>
|
||||
<label v-else class="upload-area">
|
||||
<input type="file" accept="image/*" @change="handleFileChange" :disabled="compressing" />
|
||||
<div v-if="compressing" class="compress-hint">{{ t('recharge.compressing') }}...</div>
|
||||
<div v-else class="upload-hint">{{ t('recharge.upload_hint') }}</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-submit"
|
||||
:disabled="submitting || !selectedMethod || !amount || !screenshotFile"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting">{{ t('recharge.submitting') }}...</span>
|
||||
<span v-else>{{ t('recharge.submit') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recharge-page { padding: 0 12px 24px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 22px; cursor: pointer; padding: 0 6px; }
|
||||
.history-btn { background: none; border: none; color: var(--primary-light); font-size: 12px; cursor: pointer; font-weight: 600; }
|
||||
|
||||
.state { display: flex; justify-content: center; padding: 48px; }
|
||||
|
||||
.type-tabs {
|
||||
display: flex; margin-bottom: 12px;
|
||||
border-radius: 6px; overflow: hidden;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 8px; border: none;
|
||||
background: rgba(20, 20, 20, 0.8);
|
||||
color: var(--text-muted); font-weight: 700; font-size: 13px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--primary-light); color: #000;
|
||||
}
|
||||
|
||||
.methods-list {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px;
|
||||
}
|
||||
.method-pill {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
background: rgba(20, 20, 20, 0.8);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 6px 12px;
|
||||
text-align: left; cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.method-pill.selected {
|
||||
border-color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
}
|
||||
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
|
||||
.pill-sub { font-size: 10px; color: var(--text-muted); }
|
||||
.empty-methods { text-align: center; color: var(--text-muted); padding: 20px; font-size: 12px; }
|
||||
|
||||
.method-info {
|
||||
background: rgba(17, 17, 17, 0.9);
|
||||
border-radius: 8px; padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.info-row {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
|
||||
.info-value {
|
||||
font-size: 13px; font-weight: 600;
|
||||
word-break: break-all; text-align: right;
|
||||
max-width: 60%;
|
||||
}
|
||||
.copyable {
|
||||
cursor: pointer; color: var(--primary-light);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.copyable:active { opacity: 0.6; }
|
||||
.copy-icon { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.qr-container { display: flex; justify-content: center; padding: 8px 0 4px; }
|
||||
.qr-image { width: 140px; height: 140px; object-fit: contain; border-radius: 6px; background: #fff; padding: 6px; }
|
||||
|
||||
.form-section { margin-bottom: 12px; }
|
||||
.form-section label {
|
||||
display: block; font-size: 11px; font-weight: 700;
|
||||
color: var(--text-muted); margin-bottom: 4px;
|
||||
}
|
||||
.amount-input {
|
||||
width: 100%; padding: 10px; background: #111;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: #fff; font-size: 16px; font-weight: 700;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.amount-input:focus { border-color: var(--primary-light); outline: none; }
|
||||
|
||||
.upload-area {
|
||||
border: 1px dashed rgba(212, 175, 55, 0.3);
|
||||
border-radius: 6px; padding: 16px;
|
||||
text-align: center; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.upload-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.compress-hint { font-size: 12px; color: var(--primary-light); }
|
||||
.screenshot-preview { position: relative; display: inline-block; }
|
||||
.screenshot-preview img { max-width: 100%; max-height: 160px; border-radius: 6px; }
|
||||
.remove-btn {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(0,0,0,0.7); border: none; color: #fff;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
cursor: pointer; font-size: 11px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%; padding: 12px;
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
color: #000; border: none; border-radius: 6px;
|
||||
font-size: 14px; font-weight: 800;
|
||||
cursor: pointer; margin-top: 8px;
|
||||
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.success-state { text-align: center; padding: 40px 16px; }
|
||||
.success-icon { font-size: 40px; color: #67c23a; margin-bottom: 10px; }
|
||||
.success-state h3 { margin: 0 0 6px; font-size: 16px; }
|
||||
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
|
||||
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
color: #000; border: none; border-radius: 6px;
|
||||
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
161
apps/player/src/views/RegisterView.vue
Normal file
161
apps/player/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import loginBg from '../assets/images/h5bg.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const inviteCode = ref(typeof route.query.code === 'string' ? route.query.code : '');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (!captchaRef.value?.validate()) {
|
||||
error.value = t('auth.captcha_wrong');
|
||||
captchaRef.value?.refresh();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.register(username.value, password.value, inviteCode.value);
|
||||
initFromUser(auth.user?.locale);
|
||||
const redirectTo = (route.query.redirect as string) || '/';
|
||||
router.push(redirectTo);
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('auth.register_failed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push({ path: '/login', query: route.query.redirect ? { redirect: route.query.redirect as string } : {} });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
|
||||
<div class="login-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<h2 class="form-title">{{ t('auth.register') }}</h2>
|
||||
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
|
||||
<input v-model="inviteCode" class="ps-gold-input" autocomplete="off" />
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input v-model="username" class="ps-gold-input" required autocomplete="username" />
|
||||
<label>{{ t('auth.password') }}</label>
|
||||
<input v-model="password" class="ps-gold-input" type="password" required autocomplete="new-password" minlength="8" />
|
||||
<RobotVerify ref="captchaRef" />
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||
{{ t('auth.register_btn') }}
|
||||
</button>
|
||||
<button type="button" class="btn-skip" @click="goLogin">
|
||||
{{ t('auth.have_account') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-lang {
|
||||
position: absolute;
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 28vh 20px calc(12vh + max(28px, env(safe-area-inset-bottom)));
|
||||
background-color: var(--tertiary);
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.optional-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-skip {
|
||||
margin-top: 2px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-skip:active {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user