Files
thebet365/apps/player/src/components/BetHistoryCard.vue
Mars 844727c82e feat: 前台匿名浏览、登录引导、客服入口与返水增强
前台:
- 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览)
- 顶部新增客服入口与 iframe 弹窗
- 登录页支持暂不登录返回浏览

API:
- 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头
- JWT 守卫支持可选认证

返水:
- 注单新增 is_cashbacked 字段,发放时自动标记
- 预览展示玩家余额,明确平台直发不从代理扣款
- 后台注单列表与玩家历史展示回水状态

其他:
- 串关禁止同场重复选号(SAME_MATCH)
- 补充结算资金流分析文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 09:36:44 +08:00

275 lines
6.7 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 { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { formatMoney } from '../utils/localeDisplay';
import StatusWatermark from './StatusWatermark.vue';
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../utils/matchPhase';
export interface BetScore {
ht: string | null;
ft: string | null;
}
export interface BetHistoryItem {
betNo: string;
betType: string;
stake: unknown;
totalOdds?: unknown;
potentialReturn: unknown;
actualReturn: unknown;
status: string;
placedAt: string;
isCashbacked?: boolean;
leagueName?: string;
matchTitle: string;
pickLabel: string;
isParlay?: boolean;
legCount?: number;
matchScore?: BetScore | null;
matchPhase?: MatchPhase | null;
legs?: Array<{
marketLabel: string;
selectionName: string;
matchTitle: string;
odds: unknown;
resultStatus?: string | null;
score?: BetScore | null;
}>;
}
const props = defineProps<{ bet: BetHistoryItem }>();
const { t, locale } = useI18n();
const router = useRouter();
const statusKey = computed(() => {
const s = props.bet.status.toUpperCase();
if (s === 'WON' || s === 'WIN') return 'won';
if (s === 'LOST' || s === 'LOSE') return 'lost';
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return 'push';
return 'pending';
});
const statusLabel = computed(() => t(`history.status_${statusKey.value}`));
const watermarkLabel = computed(() => {
if (statusKey.value === 'pending' && props.bet.matchPhase === 'closed_pending') {
return matchPhaseLabel(t, 'closed_pending');
}
if (statusKey.value === 'pending' && props.bet.matchPhase === 'settled') {
return matchPhaseLabel(t, 'settled');
}
return statusLabel.value;
});
const watermarkVariant = computed(() => {
if (statusKey.value === 'pending' && props.bet.matchPhase === 'closed_pending') return 'closed';
if (statusKey.value === 'pending' && props.bet.matchPhase === 'settled') return 'settled';
return statusKey.value;
});
const placedDate = computed(() =>
new Date(props.bet.placedAt).toLocaleDateString(locale.value, {
month: 'short', day: 'numeric',
}),
);
const title = computed(() => {
if (props.bet.isParlay) {
const n = props.bet.legCount ?? props.bet.legs?.length ?? 0;
return t('history.parlay_title', { n });
}
return props.bet.matchTitle;
});
const subtitle = computed(() => {
if (props.bet.isParlay) return props.bet.leagueName || t('history.parlay_league');
return props.bet.pickLabel || props.bet.leagueName || '';
});
const stakeAmount = computed(() => formatMoney(props.bet.stake, locale.value));
const oddsText = computed(() => {
const o = props.bet.totalOdds;
if (o == null || o === '' || o === 0) return '';
const n = parseFloat(String(o));
return Number.isFinite(n) ? n.toFixed(2) : String(o);
});
const betTypeLabel = computed(() => {
if (props.bet.isParlay) return t('bet.parlay');
return props.bet.betType || '';
});
const returnAmount = computed(() => {
if (statusKey.value === 'won') return formatMoney(props.bet.actualReturn, locale.value);
if (statusKey.value === 'pending') return formatMoney(props.bet.potentialReturn, locale.value);
if (statusKey.value === 'lost') return formatMoney(-parseFloat(String(props.bet.stake ?? 0)), locale.value);
return formatMoney(props.bet.actualReturn ?? props.bet.potentialReturn, locale.value);
});
const returnHighlight = computed(() => statusKey.value === 'won');
const returnPending = computed(() => statusKey.value === 'pending');
const returnLost = computed(() => statusKey.value === 'lost');
function goDetail() {
router.push(`/bets/${props.bet.betNo}`);
}
</script>
<template>
<article class="bet-card" @click="goDetail">
<StatusWatermark :label="watermarkLabel" :variant="watermarkVariant" />
<div class="card-body">
<div class="card-header">
<span v-if="betTypeLabel" class="bet-type-tag">{{ betTypeLabel }}</span>
<span v-if="bet.isCashbacked" class="cashback-tag">{{ t('history.cashbacked') }}</span>
<span class="card-date">{{ placedDate }}</span>
</div>
<span class="title">{{ title }}</span>
<div class="card-detail-row">
<span class="pick-label">{{ subtitle }}</span>
<span v-if="oddsText" class="odds-badge">@{{ oddsText }}</span>
</div>
<div class="card-footer">
<span class="stake-amt">{{ t('history.stake') }} {{ stakeAmount }}</span>
<span class="return-amt" :class="{ highlight: returnHighlight, pending: returnPending, lost: returnLost }">
{{ returnAmount }}
</span>
</div>
</div>
<span class="chevron"></span>
</article>
</template>
<style scoped>
.bet-card {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
}
.bet-card:active {
background: rgba(255, 255, 255, 0.02);
}
.card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.cashback-tag {
font-size: 10px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.25);
border-radius: 4px;
padding: 1px 6px;
}
.bet-type-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary-light, #c8a84e);
background: rgba(200, 168, 78, 0.12);
border: 1px solid rgba(200, 168, 78, 0.25);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.02em;
}
.card-date {
font-size: 10px;
color: #555;
font-weight: 600;
flex-shrink: 0;
margin-left: auto;
}
.title {
font-size: 14px;
font-weight: 800;
color: #e8e8e8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-detail-row {
display: flex;
align-items: center;
gap: 6px;
}
.pick-label {
font-size: 11px;
color: #888;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.odds-badge {
font-size: 11px;
font-weight: 800;
color: #c8a84e;
background: rgba(200, 168, 78, 0.1);
border-radius: 4px;
padding: 1px 6px;
flex-shrink: 0;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
}
.stake-amt {
font-size: 11px;
color: #666;
font-weight: 600;
}
.return-amt {
font-size: 15px;
font-weight: 900;
color: #888;
}
.return-amt.highlight { color: #3db865; }
.return-amt.pending { color: #e8c84a; }
.return-amt.lost { color: #e05050; }
.chevron {
flex-shrink: 0;
font-size: 18px;
color: #333;
margin-left: 2px;
line-height: 1;
}
</style>