Files
thebet365/apps/player/src/views/RechargeView.vue
Mars d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00

684 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import imageCompression from 'browser-image-compression';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { trackPendingOrder } = useDepositNotifications();
const reapplyOrderId = computed(() => {
const id = route.query.orderId;
return typeof id === 'string' && id ? id : '';
});
const isReapply = computed(() => !!reapplyOrderId.value);
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);
function applyReapplyQuery() {
const type = route.query.methodType;
if (type === 'BANK' || type === 'USDT') {
methodType.value = type;
}
const methodId = typeof route.query.methodId === 'string' ? route.query.methodId : '';
if (methodId) {
const match = methods.value.find((m) => m.id === methodId);
if (match) {
selectedMethod.value = match;
}
}
if (!selectedMethod.value && currentMethods.value.length) {
selectedMethod.value = currentMethods.value[0];
}
const amountQuery = typeof route.query.amount === 'string' ? route.query.amount : '';
const parsedAmount = parseFloat(amountQuery);
if (amountQuery && parsedAmount > 0) {
amount.value = amountQuery;
}
}
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) }));
if (isReapply.value) {
applyReapplyQuery();
} else 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;
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 300 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 0.3,
maxWidthOrHeight: 1200,
useWebWorker: true,
maxIteration: 10,
fileType: 'image/webp',
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.8 },
{ ...baseOptions, initialQuality: 0.6, maxWidthOrHeight: 1000 },
];
let compressed: File | null = null;
for (const options of attempts) {
try {
compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
break;
}
} catch (err) {
console.error('Compression attempt failed:', err);
}
}
if (!compressed) {
throw new Error('COMPRESS_FAILED');
}
const name = file.name.replace(/\.[^/.]+$/, '') + '.webp';
return new File([compressed], name, {
type: 'image/webp',
lastModified: Date.now(),
});
}
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;
}
if (file.size > MAX_ORIGINAL_BYTES) {
alert(t('recharge.file_too_large'));
input.value = '';
return;
}
compressing.value = true;
try {
const compressed = await compressScreenshot(file);
screenshotFile.value = compressed;
screenshotPreview.value = URL.createObjectURL(compressed);
} catch {
alert(t('recharge.compress_failed'));
screenshotFile.value = null;
screenshotPreview.value = '';
input.value = '';
} 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;
}
if (screenshotFile.value.size > MAX_SCREENSHOT_BYTES) {
alert(t('recharge.compress_failed'));
return;
}
submitting.value = true;
try {
const fd = new FormData();
fd.append('amount', String(amt));
fd.append('screenshot', screenshotFile.value);
if (isReapply.value) {
fd.append('paymentMethodId', selectedMethod.value.id);
const { data } = await api.post(`/player/deposit-orders/${reapplyOrderId.value}/reapply`, fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
return;
}
fd.append('paymentMethodId', selectedMethod.value.id);
const { data } = await api.post('/player/deposit-orders', fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
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);
}
function formatCardNumber(num: string | null): string {
if (!num) return '—';
const digits = num.replace(/\s/g, '');
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim();
}
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 v-if="isReapply" class="btn-primary" @click="goHistory">{{ t('recharge.back_to_history') }}</button>
<button v-else class="btn-primary" @click="resetForm">{{ t('recharge.new_recharge') }}</button>
</div>
<template v-else>
<div v-if="isReapply" class="reapply-banner">{{ t('recharge.reapply_hint') }}</div>
<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 && selectedMethod.methodType === 'BANK'" class="bank-card-wrap">
<div class="bank-card-face">
<div class="bank-card-shine" aria-hidden="true" />
<div class="bank-card-deco bank-card-deco--1" aria-hidden="true" />
<div class="bank-card-deco bank-card-deco--2" aria-hidden="true" />
<div class="bank-card-top">
<div class="bank-card-bank">{{ selectedMethod.bankName }}</div>
</div>
<div class="bank-card-number-row">
<span class="bank-card-number">{{ formatCardNumber(selectedMethod.accountNumber) }}</span>
<button
type="button"
class="bank-card-copy"
:aria-label="t('recharge.account_number')"
@click="copyText(selectedMethod.accountNumber || '')"
>
<svg 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>
</button>
</div>
<div class="bank-card-bottom">
<div class="bank-card-holder">
<span class="bank-card-holder-label">{{ t('recharge.account_holder') }}</span>
<span class="bank-card-holder-name">{{ selectedMethod.accountHolder }}</span>
</div>
<div class="bank-card-type">DEBIT</div>
</div>
</div>
</div>
<div v-else-if="selectedMethod" class="method-info">
<template v-if="selectedMethod.methodType !== 'BANK'">
<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; color: var(--text); }
.back-btn { background: none; border: none; color: var(--primary); font-size: 22px; cursor: pointer; padding: 0 6px; }
.history-btn { background: none; border: none; color: var(--primary); font-size: 12px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.reapply-banner {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.45;
color: var(--danger);
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.type-tabs {
display: flex; margin-bottom: 12px;
border-radius: 6px; overflow: hidden;
border: 1px solid var(--border);
}
.tab {
flex: 1; padding: 8px; border: none;
background: var(--bg-card);
color: var(--text-muted); font-weight: 700; font-size: 13px;
cursor: pointer; transition: all 0.2s;
}
.tab.active {
background: var(--primary); color: #FFFFFF;
}
.methods-list {
display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px;
}
.method-pill {
display: flex; flex-direction: column; gap: 1px;
background: var(--bg-card);
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(--border-active);
background: rgba(244, 162, 97, 0.1);
}
.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; }
.bank-card-wrap {
margin-bottom: 16px;
perspective: 800px;
}
.bank-card-face {
position: relative;
aspect-ratio: 2 / 1;
border-radius: 14px;
padding: 14px 18px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
background: linear-gradient(135deg, #0F2027 0%, #F4A261 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: var(--shadow);
}
.bank-card-shine {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.03) 45%,
rgba(255, 255, 255, 0.06) 50%,
rgba(255, 255, 255, 0.03) 55%,
transparent 70%
);
pointer-events: none;
}
.bank-card-deco {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.bank-card-deco--1 {
width: 180px;
height: 180px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle, rgba(255, 185, 128, 0.15), transparent 70%);
}
.bank-card-deco--2 {
width: 100px;
height: 100px;
right: 40px;
bottom: 20px;
border-color: rgba(255, 185, 128, 0.12);
}
.bank-card-top,
.bank-card-number-row,
.bank-card-bottom {
position: relative;
z-index: 1;
}
.bank-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.bank-card-bank {
font-size: 15px;
font-weight: 800;
color: #FFFFFF;
text-align: right;
letter-spacing: 0.5px;
line-height: 1.3;
max-width: 58%;
word-break: break-all;
}
.bank-card-number-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 4px 0 2px;
}
.bank-card-number {
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-size: clamp(15px, 4.2vw, 19px);
font-weight: 700;
letter-spacing: 0.14em;
color: #FFFFFF;
word-break: break-all;
line-height: 1.3;
}
.bank-card-copy {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
color: #FFFFFF;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.bank-card-copy svg {
width: 16px;
height: 16px;
}
.bank-card-copy:active {
opacity: 0.65;
background: rgba(255, 255, 255, 0.2);
}
.bank-card-bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.bank-card-holder {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bank-card-holder-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
}
.bank-card-holder-name {
font-size: 14px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 0.04em;
word-break: break-all;
}
.bank-card-type {
flex-shrink: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.method-info {
background: var(--bg-card);
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 var(--border);
}
.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%;
color: var(--text);
}
.copyable {
cursor: pointer; color: var(--primary);
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; border: 1px solid var(--border); }
.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: var(--bg-input);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 16px; font-weight: 700;
box-sizing: border-box;
}
.amount-input:focus { border-color: var(--border-active); outline: none; box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.2); }
.upload-area {
border: 1px dashed var(--primary);
border-radius: 6px; padding: 16px;
text-align: center; position: relative;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: rgba(244, 162, 97, 0.06);
}
.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); }
.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.6); border: none; color: #fff;
width: 20px; height: 20px; border-radius: 50%;
cursor: pointer; font-size: 11px;
}
.btn-submit {
width: 100%; padding: 12px;
background: var(--primary);
color: #FFFFFF; border: none; border-radius: 6px;
font-size: 14px; font-weight: 800;
cursor: pointer; margin-top: 8px;
box-shadow: var(--shadow-glow);
}
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.success-state { text-align: center; padding: 40px 16px; }
.success-icon { font-size: 40px; color: var(--success); margin-bottom: 10px; }
.success-state h3 { margin: 0 0 6px; font-size: 16px; color: var(--text); }
.order-no { font-family: monospace; color: var(--primary); font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
.btn-primary {
background: var(--primary);
color: #FFFFFF; border: none; border-radius: 6px;
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
}
</style>