feat(player): 新增桌面端 UI 与响应式双端路由架构

- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系
- 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端
- 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌
- 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n
- 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
This commit is contained in:
2026-06-29 18:01:39 +08:00
parent 9c5e8d6f5c
commit 8a1ba0fd95
116 changed files with 22249 additions and 8693 deletions

View File

@@ -1,7 +1,6 @@
<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,
@@ -10,7 +9,7 @@ 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';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
const props = defineProps<{
activated?: boolean;
@@ -18,14 +17,12 @@ const props = defineProps<{
const { t } = useI18n();
const auth = useAuthStore();
const { loading, loadError, events, load } = useOutrightEvents();
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);
@@ -37,7 +34,6 @@ const totalSelections = computed(() =>
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]]);
@@ -50,59 +46,18 @@ function syncExpandedAfterLoad() {
}
}
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);
watch(
() => events.value.length,
(len, prev) => {
if (len > 0 && prev === 0) syncExpandedAfterLoad();
},
);
// 每次切回优胜冠军 Tab 时静默刷新赔率
watch(
() => props.activated,
(active) => {
if (active && events.value.length > 0) void load();
if (active && events.value.length > 0) void load({ silent: true });
},
);