Files
thebet365/apps/player/src/views/RechargeView.vue
Mars d3ca8498fb feat(player/admin/api): 玩家端暗色极简主题重构与盘口单串关修复
## 管理端 / API(Bug 修复)
- matches.service:getAdminMatchDetail 返回 markets 时补充 allowSingle、allowParlay 字段
- MatchMarketsPanel:mapMarkets 从接口读取单关/串关开关,不再硬编码为 true
- match-form.ts:AdminMarket 类型补充 allowSingle、allowParlay

## 玩家端 — 全局主题(styles.css / index.html / site.webmanifest)
- 海军暗色 + 白色强调 token,卡片高光渐变(--gradient-card)
- 下拉/弹窗实底不透明(--dropdown-bg: #0A2540)
- 统一 sub-toolbar、status-ribbon、filter-tab 等全局样式
- theme-color 同步为 #001A33

## 玩家端 — 布局与壳层
- MainLayout:详情子页去顶栏/公告,sub-toolbar 全宽铺底
- WalletBalanceCard(新):个人页钱包卡片(反水 + 未结算)
- walletStats.ts(新):钱包统计逻辑抽取

## 玩家端 — 赛事 / 投注
- MatchDetailView:顶栏 8px 顶距、卡片全宽(--detail-gutter-x: 0)
- 盘口状态标签(暂停/已关闭)、去掉 MarketTypeTile 右侧箭头
- VsBadge(新):纯文字 VS,删除 vs.png
- isMarketLocked:仅关单关但允许串关时仍可点击,支持串关流程
- BetSlipDrawer:仅串关盘口禁用单关提交并提示 slip_parlay_only_hint
- betSlip:SlipItem 增加 allowSingle 字段
- MatchBetCard / LeagueAccordionItem:45° 待开赛角标,去掉展开左侧白边
- OutrightEventSection:去掉展开时左侧白色选中条
- FootballView:加大左右边距、筛选栏去 sticky、选中态增强
- BannerCarousel:恢复原始全宽轮播

## 玩家端 — 钱包 / 个人 / 认证 / 其他页面
- WalletView:移除顶部钱包卡片,保留账单列表
- ProfileView:保留 WalletBalanceCard
- 充值/账单/注单/登录注册等页面配色与间距统一
- 公告标签、余额面板、语言切换、区号选择等弹层改为实底

## 国际化
- zh-CN / en-US / ms-MY:新增 slip_parlay_only_hint(仅串关盘口提示)

## 资产
- 更新 banner.svg、empty-matches.svg 配色
- 删除 vs.png

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:00:25 +08:00

685 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';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
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 = 1024 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
maxIteration: 15,
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.85 },
{ ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
{ ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
];
for (const options of attempts) {
const compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
return compressed;
}
}
throw new Error('COMPRESS_TOO_LARGE');
}
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 ?? '';
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 ?? '';
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">
<header class="page-header sub-toolbar">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.title') }}</h2>
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
</header>
<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 0 24px; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
}
.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; }
.reapply-banner {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.45;
color: #ffd0d0;
background: rgba(245, 108, 108, 0.12);
border: 1px solid rgba(245, 108, 108, 0.25);
}
.type-tabs {
display: flex;
margin-bottom: 12px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-body);
}
.tab {
flex: 1;
padding: 8px;
border: none;
background: var(--bg-card);
color: var(--text-muted);
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.tab.active {
background: var(--surface-accent);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--primary);
}
.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(--primary);
background: var(--surface-accent);
}
.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:
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(255, 255, 255, 0.18), transparent 55%),
radial-gradient(ellipse 50% 50% at 0% 100%, var(--surface-accent), transparent 50%),
var(--gradient-card);
border: 1px solid var(--border-gold-soft);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.35),
0 2px 8px var(--surface-accent),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.bank-card-shine {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.04) 45%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.04) 55%,
transparent 70%
);
pointer-events: none;
}
.bank-card-deco {
position: absolute;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
pointer-events: none;
}
.bank-card-deco--1 {
width: 180px;
height: 180px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle, var(--surface-accent), transparent 70%);
}
.bank-card-deco--2 {
width: 100px;
height: 100px;
right: 40px;
bottom: 20px;
border-color: rgba(255, 255, 255, 0.25);
}
.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: #fff;
text-align: right;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
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: var(--primary-light);
text-shadow: 0 0 12px rgba(240, 216, 117, 0.25);
word-break: break-all;
line-height: 1.3;
}
.bank-card-copy {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border-gold-soft);
background: rgba(0, 34, 68, 0.45);
color: var(--primary-light);
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: var(--surface-accent);
}
.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.45);
}
.bank-card-holder-name {
font-size: 14px;
font-weight: 700;
color: #fff;
letter-spacing: 0.04em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
word-break: break-all;
}
.bank-card-type {
flex-shrink: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
color: var(--primary-light);
font-style: italic;
}
.method-info {
background: var(--bg-body);
border-radius: var(--radius-sm);
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: 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-gold-soft); outline: none; }
.upload-area {
border: 1px dashed var(--border-gold-soft);
border-radius: 6px; padding: 16px;
text-align: center; position: relative;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: var(--bg-body);
}
.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: var(--gradient-accent);
color: #fff; border: none; border-radius: 6px;
font-size: 14px; font-weight: 800;
cursor: pointer; margin-top: 8px;
box-shadow: none;
}
.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-light); font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
.btn-primary {
background: var(--gradient-accent);
color: #fff; border: none; border-radius: 6px;
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
}
</style>