feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系 - 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端 - 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌 - 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n - 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
This commit is contained in:
@@ -1,392 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { sanitizeAnnouncementHtml } from '../utils/html';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
usePlayerMessages,
|
||||
type DepositMessagePayload,
|
||||
type BannerPromoPayload,
|
||||
type PlayerMessage,
|
||||
} from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import MobileMessageDetailView from './MobileMessageDetailView.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const messageId = computed(() => String(route.params.id ?? ''));
|
||||
const message = ref<PlayerMessage | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
|
||||
const depositPayload = computed(() => {
|
||||
if (
|
||||
!message.value ||
|
||||
message.value.type === 'BANNER_PROMO' ||
|
||||
message.value.type === 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (message.value.payload ?? null) as DepositMessagePayload | null;
|
||||
});
|
||||
const contentPromoId = computed(() => {
|
||||
if (
|
||||
message.value?.type !== 'BANNER_PROMO' &&
|
||||
message.value?.type !== 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (message.value.payload as BannerPromoPayload | null)?.contentId;
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push(hubRoute.value);
|
||||
}
|
||||
|
||||
function viewContentPromo() {
|
||||
if (!contentPromoId.value) return;
|
||||
router.push(`/announcements/${contentPromoId.value}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function messageTitle(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function messageBody(item: PlayerMessage) {
|
||||
const deposit = depositPayload.value;
|
||||
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_approved_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
approvedAmount: formatMoney(
|
||||
deposit.approvedAmount ?? deposit.amount ?? '0',
|
||||
locale.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_rejected_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
|
||||
});
|
||||
}
|
||||
return item.body;
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!messageId.value) return;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
message.value = await loadMessageDetail(messageId.value);
|
||||
if (message.value && !message.value.isRead) {
|
||||
await markMessageRead(messageId.value);
|
||||
message.value = { ...message.value, isRead: true };
|
||||
}
|
||||
} catch {
|
||||
error.value = true;
|
||||
message.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleteConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteMessage(messageId.value);
|
||||
deleteConfirmVisible.value = false;
|
||||
router.replace(hubRoute.value);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
watch(messageId, () => {
|
||||
void fetchDetail();
|
||||
});
|
||||
const { isDesktop } = useViewport();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('messages.detail_title') }}</h1>
|
||||
<button
|
||||
v-if="message"
|
||||
type="button"
|
||||
class="delete-header-btn"
|
||||
:aria-label="t('messages.delete')"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('messages.delete') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !message" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !message" class="empty">
|
||||
<p>{{ t('messages.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<span class="status-badge" :class="{ unread: !message.isRead }">
|
||||
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||
</span>
|
||||
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
||||
<div
|
||||
v-if="message.type === 'ADMIN_CUSTOM'"
|
||||
class="detail-body rich-body"
|
||||
v-html="sanitizeAnnouncementHtml(message.body)"
|
||||
/>
|
||||
<p v-else class="detail-body">{{ messageBody(message) }}</p>
|
||||
|
||||
<div
|
||||
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||
class="reason-box"
|
||||
>
|
||||
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
|
||||
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="contentPromoId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="viewContentPromo"
|
||||
>
|
||||
{{ t('messages.content_promo_view') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="depositPayload?.depositOrderId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="router.push('/wallet/recharge/history')"
|
||||
>
|
||||
{{ t('messages.view_recharge_history') }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
:title="t('messages.delete')"
|
||||
:message="t('messages.delete_confirm')"
|
||||
:confirm-text="t('messages.delete')"
|
||||
danger
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
<MobileMessageDetailView :embedded="isDesktop" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #141414;
|
||||
color: var(--gold);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.delete-header-btn {
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 80, 80, 0.08);
|
||||
color: #f88;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-header-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-badge.unread {
|
||||
color: var(--gold);
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 18px 0 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;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.rich-body :deep(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.rich-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-body :deep(img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rich-body :deep(ul),
|
||||
.rich-body :deep(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.rich-body :deep(a) {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.reason-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 107, 107, 0.25);
|
||||
background: rgba(255, 107, 107, 0.08);
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #ffb4b4;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reason-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: #ffe0e0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 8px;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--gold);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user