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

@@ -0,0 +1,440 @@
<script setup lang="ts">
import { computed, onActivated, watch } from 'vue';
const { embedded = false, desktop = false } = defineProps<{ embedded?: boolean; desktop?: boolean }>();
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import defaultBannerImg from '../assets/images/banner.webp';
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { hubContentItems, loading, load } = usePlayerHome();
const announcementId = computed(() => String(route.params.id ?? ''));
const item = computed<PlayerContentItem | null>(() => {
const id = announcementId.value;
return hubContentItems.value.find((entry) => entry.id === id) ?? null;
});
const isBanner = computed(() => item.value?.contentType === 'BANNER');
function goBack() {
if (embedded) return;
if (window.history.length > 1) {
router.back();
return;
}
router.push('/announcements');
}
function goList() {
router.push('/announcements');
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function itemTitle(entry: PlayerContentItem) {
const title = entry.translation?.title?.trim();
if (title) return title;
const bodyText = stripHtml(entry.translation?.body ?? '');
return bodyText || t('home.announcement_badge');
}
function itemBodyHtml(entry: PlayerContentItem) {
const title = entry.translation?.title?.trim();
const body = entry.translation?.body?.trim();
if (!body) return '';
if (title && stripHtml(body) === title) return '';
return sanitizeAnnouncementHtml(body);
}
const heroImageUrl = computed(() => {
const url = item.value?.translation?.imageUrl?.trim();
if (url) return url;
if (isBanner.value) return defaultBannerImg || FALLBACK_IMG;
return '';
});
const linkTarget = computed(() => item.value?.linkTarget?.trim() ?? '');
const externalLinkUrl = computed(() => {
if (item.value?.linkType !== 'URL' || !linkTarget.value) return '';
return /^https?:\/\//i.test(linkTarget.value)
? linkTarget.value
: `https://${linkTarget.value}`;
});
function onHeroError(e: Event) {
const img = e.target as HTMLImageElement;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = defaultBannerImg || FALLBACK_IMG;
}
function followRouteLink() {
if (item.value?.linkType === 'ROUTE' && linkTarget.value) {
void router.push(linkTarget.value);
}
}
function openExternalLink() {
if (externalLinkUrl.value) {
window.open(externalLinkUrl.value, '_blank', 'noopener');
}
}
async function refresh() {
await load(true);
}
onActivated(() => {
void refresh();
});
watch(announcementId, () => {
if (!item.value && !loading.value) void refresh();
});
</script>
<template>
<div class="announce-detail" :class="{ 'announce-detail--embedded': embedded, 'announce-detail--desktop': desktop }">
<header v-if="embedded" class="page-header page-header--embedded-detail">
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<header v-else-if="desktop" class="page-header page-header--desktop">
<button type="button" class="back-btn" @click="goBack">
<span class="back-icon" aria-hidden="true"></span>
{{ t('announcements.back') }}
</button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<header v-else class="page-header">
<button type="button" class="back-btn back-btn--icon" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<div v-if="loading && !item" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!item" class="empty">
<p>{{ t('announcements.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
</div>
<article v-else class="detail-article">
<figure v-if="heroImageUrl" class="detail-hero">
<img :src="heroImageUrl" :alt="itemTitle(item)" loading="lazy" @error="onHeroError" />
</figure>
<div class="detail-body-wrap">
<p v-if="item.createdAt" class="detail-date">{{ formatDate(item.createdAt) }}</p>
<h2 class="detail-title">{{ itemTitle(item) }}</h2>
<div v-if="itemBodyHtml(item)" class="detail-body" v-html="itemBodyHtml(item)" />
<div v-if="item.linkType && linkTarget" class="detail-link">
<p class="link-label">{{ t('announcements.related_link') }}</p>
<p class="link-address">{{ linkTarget }}</p>
<button
v-if="item.linkType === 'ROUTE'"
type="button"
class="link-action"
@click="followRouteLink"
>
{{ t('announcements.go_link') }}
</button>
<button
v-else-if="item.linkType === 'URL'"
type="button"
class="link-action link-action--outline"
@click="openExternalLink"
>
{{ t('announcements.open_link') }}
</button>
</div>
</div>
</article>
</div>
</template>
<style scoped>
.announce-detail {
min-height: 100%;
padding: 0 0 24px;
}
.announce-detail--embedded {
padding: 0;
min-height: 0;
}
.announce-detail--desktop {
padding: 0;
min-height: 0;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
margin-bottom: 0;
border-bottom: 1px solid var(--border);
}
.page-header--desktop {
padding: 0 0 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.page-header--embedded-detail {
padding: 0 0 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.page-header--embedded-detail h1 {
font-size: 16px;
color: var(--primary-light);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
font-size: 13px;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
}
.back-btn--icon {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
font-size: 22px;
}
.back-icon {
font-size: 18px;
line-height: 1;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.page-header--desktop h1 {
font-size: 16px;
color: var(--primary-light);
}
.state,
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted);
}
.link-btn {
border: 1px solid var(--border-gold-soft);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
font-size: 13px;
font-weight: 600;
}
.detail-article {
margin: 0;
padding: 0;
}
.announce-detail--embedded .detail-article,
.announce-detail--desktop .detail-article {
background: rgba(18, 18, 18, 0.92);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px 24px;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: #080808;
line-height: 0;
}
.announce-detail--embedded .detail-hero,
.announce-detail--desktop .detail-hero {
margin: 0 0 18px;
max-height: min(280px, 36vh);
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #0a0a0a;
}
.detail-hero img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
object-position: center;
}
.announce-detail--embedded .detail-hero img,
.announce-detail--desktop .detail-hero img {
max-height: min(280px, 36vh);
width: auto;
max-width: 100%;
}
.detail-body-wrap {
padding: 18px 0 0;
}
.announce-detail--embedded .detail-body-wrap,
.announce-detail--desktop .detail-body-wrap {
padding: 0;
}
.detail-date {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-muted);
}
.detail-title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: #fff;
}
.announce-detail--embedded .detail-title,
.announce-detail--desktop .detail-title {
font-size: 18px;
margin-bottom: 10px;
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
}
.detail-body :deep(p) {
margin: 0 0 12px;
}
.detail-body :deep(p:last-child) {
margin-bottom: 0;
}
.detail-body :deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 14px 0;
border-radius: 8px;
}
.announce-detail--embedded .detail-body :deep(img),
.announce-detail--desktop .detail-body :deep(img) {
max-height: min(320px, 40vh);
width: auto;
max-width: 100%;
margin-left: auto;
margin-right: auto;
object-fit: contain;
}
.detail-body :deep(ul),
.detail-body :deep(ol) {
margin: 0 0 12px;
padding-left: 20px;
}
.detail-body :deep(a) {
color: var(--primary-light);
text-decoration: underline;
}
.detail-link {
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.link-label {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-muted);
}
.link-address {
margin: 0 0 14px;
font-size: 14px;
line-height: 1.55;
color: var(--primary-light);
word-break: break-all;
}
.link-action {
width: 100%;
padding: 11px 14px;
border: none;
border-radius: 8px;
background: var(--gradient-gold);
color: #1a1000;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.link-action--outline {
background: transparent;
border: 1px solid var(--border-gold-soft);
color: var(--gold);
}
</style>