Files
thebet365/apps/player/src/views/WalletTransactionDetailView.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

385 lines
9.8 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 { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey, isCashbackType, txDisplayType, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
type TransactionDetail = {
transactionId: string;
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
amount: string;
balanceBefore: string;
balanceAfter: string;
frozenBefore: string;
frozenAfter: string;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
createdAt: string;
betNo: string | null;
cashbackBatchNo?: string | null;
};
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const tx = ref<TransactionDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
async function loadTransaction() {
loading.value = true;
try {
const { data } = await api.get(`/player/wallet/transactions/${route.params.transactionId}`);
if (!data.data) {
notFound.value = true;
return;
}
tx.value = data.data;
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadTransaction);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadTransaction,
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
function txLabel(type: string): string {
const key = txTypeKey(type);
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return type;
}
const displayTypeLabel = computed(() => {
if (!tx.value) return '';
return txLabel(txDisplayType(tx.value));
});
const summaryText = computed(() => {
if (!tx.value) return '';
return txSummaryLabel(tx.value, t);
});
const amountClass = computed(() => {
if (!tx.value) return '';
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
});
const formattedTime = computed(() => {
if (!tx.value) return '';
return new Date(tx.value.createdAt).toLocaleString(locale.value);
});
const frozenChanged = computed(() => {
if (!tx.value) return false;
return tx.value.frozenBefore !== tx.value.frozenAfter;
});
const isCashbackTx = computed(
() => !!tx.value && isCashbackType(tx.value.transactionType),
);
const cashbackBatchNo = computed(() => {
if (!isCashbackTx.value) return null;
return tx.value?.cashbackBatchNo ?? tx.value?.referenceId ?? null;
});
const referenceLabel = computed(() => {
if (isCashbackTx.value) return t('wallet.ref_cashback');
if (!tx.value?.referenceType) return '';
if (isDepositReversalType(tx.value.transactionType)) return t('wallet.ref_deposit');
const rt = tx.value.referenceType.toUpperCase();
if (rt === 'BET') return t('wallet.ref_bet');
if (rt === 'DEPOSIT') return t('wallet.ref_deposit');
if (rt === 'WITHDRAW') return t('wallet.ref_withdraw');
return tx.value.referenceType;
});
const remarkText = computed(() => {
if (!tx.value) return '';
return txRemarkLabel(tx.value, t);
});
function goBetDetail() {
if (!tx.value?.betNo) return;
router.push(`/bets/${tx.value.betNo}`);
}
function goCashbackDetail() {
if (!cashbackBatchNo.value) return;
router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
}
</script>
<template>
<div class="detail-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="top-bar sub-toolbar">
<button class="back-btn" type="button" @click="router.back()"> {{ t('history.back') }}</button>
</header>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="notFound || !tx" class="state">{{ t('wallet.detail_not_found') }}</div>
<template v-else>
<div class="hero" :class="amountClass">
<span class="hero-type">{{ displayTypeLabel }}</span>
<span v-if="summaryText" class="hero-summary">{{ summaryText }}</span>
<span class="hero-amount">{{ formatMoney(tx.amount, locale) }}</span>
<span class="hero-time">{{ formattedTime }}</span>
</div>
<section class="section">
<div class="section-title">{{ t('wallet.detail_summary') }}</div>
<div class="summary-rows">
<div class="sum-row">
<span>{{ t('wallet.detail_amount') }}</span>
<span :class="amountClass">{{ formatMoney(tx.amount, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_balance_before') }}</span>
<span>{{ formatMoney(tx.balanceBefore, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_balance_after') }}</span>
<span>{{ formatMoney(tx.balanceAfter, locale) }}</span>
</div>
<template v-if="frozenChanged">
<div class="sum-row">
<span>{{ t('wallet.detail_frozen_before') }}</span>
<span>{{ formatMoney(tx.frozenBefore, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_frozen_after') }}</span>
<span>{{ formatMoney(tx.frozenAfter, locale) }}</span>
</div>
</template>
</div>
</section>
<section v-if="tx.referenceType || remarkText" class="section">
<div class="section-title">{{ t('wallet.detail_reference') }}</div>
<div class="summary-rows">
<div v-if="tx.referenceType" class="sum-row">
<span>{{ t('wallet.detail_reference_type') }}</span>
<span>{{ referenceLabel }}</span>
</div>
<div v-if="tx.referenceId && !tx.betNo" class="sum-row">
<span>{{ t('wallet.detail_reference_id') }}</span>
<span class="mono">{{ tx.referenceId }}</span>
</div>
<div v-if="remarkText" class="sum-row">
<span>{{ t('wallet.detail_remark') }}</span>
<span class="remark">{{ remarkText }}</span>
</div>
</div>
<button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail">
{{ t('wallet.detail_bet_link') }} · {{ tx.betNo }}
</button>
<button v-if="cashbackBatchNo" type="button" class="bet-link" @click="goCashbackDetail">
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
</button>
</section>
<section class="section">
<div class="section-title">{{ t('wallet.detail_tx_id') }}</div>
<div class="tx-id">{{ tx.transactionId }}</div>
</section>
</template>
</div>
</template>
<style scoped>
.detail-page {
padding-bottom: 32px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.top-bar {
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.hero {
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 12px;
text-align: center;
background: var(--bg-card);
border: 1px solid var(--border);
}
.hero.pos {
border-color: rgba(46, 204, 113, 0.35);
background: linear-gradient(135deg, rgba(46, 204, 113, 0.16), rgba(46, 204, 113, 0.05));
}
.hero.neg {
border-color: rgba(255, 255, 255, 0.35);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
}
.hero-type {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.hero-summary {
font-size: 13px;
font-weight: 600;
color: var(--text);
max-width: 100%;
word-break: break-all;
}
.hero-amount {
font-size: 34px;
font-weight: 900;
line-height: 1.1;
}
.hero.pos .hero-amount { color: var(--success); }
.hero.neg .hero-amount { color: var(--primary); }
.hero-time {
font-size: 11px;
color: var(--neutral);
font-weight: 600;
}
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
}
.section-title {
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 0.04em;
margin-bottom: 10px;
}
.summary-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.sum-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
font-size: 13px;
color: var(--text-muted);
}
.sum-row span:last-child {
color: var(--text);
font-weight: 700;
text-align: right;
word-break: break-all;
}
.pos { color: var(--success) !important; }
.neg { color: var(--primary) !important; }
.mono {
font-family: ui-monospace, monospace;
font-size: 12px;
}
.remark {
max-width: 60%;
line-height: 1.4;
}
.bet-link {
width: 100%;
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.tx-id {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
line-height: 1.5;
}
</style>