Files
thebet365/apps/player/src/components/outright/OutrightPanel.vue
Mars d3c211411b feat(admin+api+player): 优胜赛结算态、禁止新增单场与钱包流水展示优化
- API/管理端:优胜赛 SETTLED 后禁止新增单场,列表与子页展示结算状态
- 玩家端:已结算 outright 只读展示并高亮冠军
- 管理端:结算后 stale 标记驱动列表刷新;财务流水时间与备注 i18n 优化
- shared:txDisplayAmount 与 LEAGUE_OUTRIGHT_SETTLED 错误码
2026-06-23 12:20:40 +08:00

226 lines
5.6 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, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import OutrightEventSection, {
type OutrightEvent,
type OutrightSelection,
} from './OutrightEventSection.vue';
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
import { useAuthStore } from '../../stores/auth';
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const props = defineProps<{
activated?: boolean;
}>();
const { t } = useI18n();
const auth = useAuthStore();
function goLogin() {
auth.showLoginPrompt('/bet');
}
const loading = ref(true);
const loadError = ref('');
const events = ref<OutrightEvent[]>([]);
const expanded = ref<Set<string>>(new Set());
const modalOpen = ref(false);
const activePick = ref<OutrightPick | null>(null);
const eventCount = computed(() => events.value.length);
const totalSelections = computed(() =>
events.value.reduce((sum, e) => sum + e.selections.length, 0),
);
function syncExpandedAfterLoad() {
const ids = events.value.map((e) => e.id);
// 只保留仍然存在的 id且最多保留 1 个
const kept = [...expanded.value].filter((id) => ids.includes(id));
if (kept.length > 0) {
expanded.value = new Set([kept[0]]);
return;
}
if (ids.length > 0) {
expanded.value = new Set([ids[0]]);
} else {
expanded.value = new Set();
}
}
async function load() {
const hadData = events.value.length > 0;
if (!hadData) loading.value = true;
loadError.value = '';
try {
const { data } = await api.get('/player/outrights');
const list = (data?.data ?? []) as OutrightEvent[];
const fresh = list.filter((e) => e.selections?.length > 0);
if (!hadData) {
events.value = fresh;
syncExpandedAfterLoad();
} else {
mergeOddsOnly(fresh);
}
} catch (e: unknown) {
if (!hadData) events.value = [];
const err = e as { response?: { status?: number; data?: { error?: string } } };
if (err.response?.status === 403) {
loadError.value = t('bet.outright_player_only');
} else {
loadError.value = err.response?.data?.error ?? t('bet.outright_load_failed');
}
} finally {
if (!hadData) loading.value = false;
}
}
function mergeOddsOnly(fresh: OutrightEvent[]) {
const freshMap = new Map<string, OutrightEvent>();
for (const e of fresh) freshMap.set(e.id, e);
for (const event of events.value) {
const freshEvent = freshMap.get(event.id);
if (!freshEvent) continue;
const selMap = new Map<string, OutrightSelection>();
for (const s of freshEvent.selections) selMap.set(s.id, s);
for (const sel of event.selections) {
const fs = selMap.get(sel.id);
if (fs) {
sel.odds = fs.odds;
sel.oddsVersion = fs.oddsVersion;
}
}
}
}
useOnLocaleChange(load);
// 每次切回优胜冠军 Tab 时静默刷新赔率
watch(
() => props.activated,
(active) => {
if (active && events.value.length > 0) void load();
},
);
function toggle(id: string) {
const next = new Set(expanded.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expanded.value = next;
}
function openBet(event: OutrightEvent, sel: OutrightSelection) {
if (event.bettingOpen === false || event.status === 'SETTLED') return;
if (!auth.token) {
goLogin();
return;
}
activePick.value = {
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
teamCode: sel.teamCode,
teamName: sel.teamName,
odds: sel.odds,
eventTitle: event.title,
};
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
activePick.value = null;
}
</script>
<template>
<div class="outright-panel">
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else-if="events.length">
<p v-if="eventCount > 1" class="panel-summary">
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
</p>
<p v-if="events.some((e) => e.bettingOpen === false || e.status === 'SETTLED')" class="panel-settled-hint">
{{ t('bet.outright_settled_hint') }}
</p>
<div class="event-list">
<OutrightEventSection
v-for="event in events"
:key="event.id"
:event="event"
:expanded="expanded.has(event.id)"
@toggle="toggle(event.id)"
@pick="openBet(event, $event)"
/>
</div>
</template>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p v-if="loadError">{{ loadError }}</p>
<p v-else>{{ t('bet.no_outright') }}</p>
<p v-if="!loadError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
</div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
</div>
</template>
<style scoped>
.outright-panel {
padding: 4px 12px 0;
}
.panel-summary {
margin: 0 0 12px;
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.4;
}
.panel-settled-hint {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(201, 162, 39, 0.08);
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
line-height: 1.45;
}
.event-list {
padding-bottom: 8px;
}
.state,
.empty {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.empty-hint {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
</style>