feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强

API:
- 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板
- 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数
- User 表增加 visible_menus 字段;新增 player_messages 表及迁移
- 充值审核通过/拒绝时按系统配置自动写入玩家站内消息
- 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count
- 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置
- 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管)
- SystemConfig 增加 inbox 功能开关及各类通知开关

Admin:
- 员工管理:按角色默认菜单 + 可勾选可见菜单项
- ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询
- Contents:富文本编辑器、图片字段组件重构
- DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列
- 多页面 i18n 与权限细节调整

Player:
- 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除
- 公告列表与详情页;走马灯可跳转详情
- 客服 Modal 改为 Panel,与邮箱 Hub 整合
- 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认
- HomeView 今日赛事板块;FootballView 等体验优化

Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码
Docs: 玩家端缺失功能分析文档
Chore: 移除 .agents/skills 设计类 skill 文件
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 17:51:10 +08:00
parent e9a23de935
commit f9343b00af
105 changed files with 6960 additions and 7523 deletions

View File

@@ -1,23 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const props = withDefaults(
defineProps<{ items: string[]; embedded?: boolean }>(),
defineProps<{ items: string[]; targetId?: string; embedded?: boolean }>(),
{ embedded: false },
);
const detailTo = computed(() =>
props.targetId ? `/announcements/${props.targetId}` : '/announcements',
);
const text = computed(() => {
const list = props.items.filter(Boolean);
if (!list.length) return '';
return list.join('  ◆  ');
});
function goDetail() {
void router.push(detailTo.value);
}
</script>
<template>
<div v-if="text" class="marquee-bar" :class="{ embedded }">
<button
v-if="text"
type="button"
class="marquee-bar"
:class="{ embedded }"
@click="goDetail"
>
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
<div class="marquee-viewport">
<div class="marquee-track">
@@ -25,11 +41,12 @@ const text = computed(() => {
<span class="marquee-text" aria-hidden="true">{{ text }}</span>
</div>
</div>
</div>
</button>
</template>
<style scoped>
.marquee-bar {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
@@ -39,6 +56,11 @@ const text = computed(() => {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
text-decoration: none;
color: inherit;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.marquee-bar.embedded {
@@ -82,6 +104,7 @@ const text = computed(() => {
display: flex;
width: max-content;
animation: marquee-scroll 18s linear infinite;
pointer-events: none;
}
.marquee-text {

View File

@@ -13,7 +13,11 @@ export interface BannerItem {
translation?: { title?: string; body?: string; imageUrl?: string };
}
const props = defineProps<{ banners: BannerItem[] }>();
const props = defineProps<{
banners: BannerItem[];
/** 未配置跳转链接时的默认路由 */
fallbackTo?: string;
}>();
const router = useRouter();
const active = ref(0);
@@ -51,14 +55,12 @@ function prev() {
}
function onBannerClick(banner: BannerItem) {
if (banner.linkType === 'ROUTE' && banner.linkTarget) {
router.push(banner.linkTarget);
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
}
if (banner.linkType === 'URL' && banner.linkTarget) {
let url = banner.linkTarget.trim();
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
window.open(url, '_blank');
if (props.fallbackTo) {
void router.push(props.fallbackTo);
}
}
@@ -201,6 +203,8 @@ onUnmounted(stopAutoPlay);
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
#070d15;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.slide::after {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import {
@@ -37,6 +37,27 @@ const MIN_STAKE = 5;
const MAX_STAKE_INTEGER_LENGTH = 9;
const stakeInput = ref('');
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
const ODDS_POLL_MS = 5000;
type OddsDelta = {
oldOdds: number;
newOdds: number;
newVersion: string;
suspended: boolean;
};
interface SelectionOddsRow {
id: string;
odds: string;
oddsVersion: string;
status: string;
marketStatus: string;
marketShowOnPlayer: boolean;
matchStatus: string;
}
const oddsDeltas = ref<Record<string, OddsDelta>>({});
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
const activeItems = computed<SlipItem[]>(() => {
if (activeTab.value === 'parlay') return slip.parlayItems;
@@ -44,20 +65,47 @@ const activeItems = computed<SlipItem[]>(() => {
});
const activeCount = computed(() => activeItems.value.length);
function effectiveOdds(item: SlipItem) {
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
}
const activeTotalOdds = computed(() =>
activeItems.value.reduce((acc, item) => acc * item.odds, 1),
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
);
const activeEstimatedReturn = computed(() => {
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
return slip.stake * activeItems.value[0].odds;
return slip.stake * effectiveOdds(activeItems.value[0]);
});
const hasSuspendedSelections = computed(() =>
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
);
const hasPendingOddsChanges = computed(() =>
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
);
const oddsWarningText = computed(() => {
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
return '';
});
const submitButtonLabel = computed(() => {
if (loading.value) return t('bet.placing');
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
return t('bet.place_bet_short');
});
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
const canSubmitActive = computed(() => {
if (activeTab.value === 'parlay') {
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
}
return Boolean(slip.singleItem) && slip.singleItem.allowSingle !== false;
return Boolean(slip.singleItem) && slip.singleItem!.allowSingle !== false;
});
const singleParlayOnlyHint = computed(
@@ -222,6 +270,81 @@ function setMaxStake() {
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
}
function stopOddsPolling() {
if (oddsPollTimer) {
clearInterval(oddsPollTimer);
oddsPollTimer = null;
}
oddsDeltas.value = {};
}
function acceptPendingOdds() {
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
if (!delta.suspended) {
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
}
}
oddsDeltas.value = {};
}
async function pollSelectionsOdds() {
const items = activeItems.value;
if (!items.length || !show.value) return;
try {
const ids = items.map((item) => item.selectionId).join(',');
const { data } = await api.get('/player/selections/odds', { params: { ids } });
const rows: SelectionOddsRow[] = data.data?.items ?? [];
const rowMap = new Map(rows.map((row) => [row.id, row]));
const next: Record<string, OddsDelta> = {};
for (const item of items) {
const row = rowMap.get(item.selectionId);
if (!row) continue;
const suspended =
row.status !== 'OPEN' ||
row.marketStatus !== 'OPEN' ||
row.marketShowOnPlayer === false ||
row.matchStatus !== 'PUBLISHED';
const newOdds = parseFloat(row.odds);
const versionChanged = row.oddsVersion !== item.oddsVersion;
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
if (suspended || versionChanged || oddsChanged) {
const existing = oddsDeltas.value[item.selectionId];
next[item.selectionId] = {
oldOdds: existing?.oldOdds ?? item.odds,
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
newVersion: row.oddsVersion,
suspended,
};
}
}
oddsDeltas.value = next;
} catch {
/* silent retry on next tick */
}
}
function startOddsPolling() {
stopOddsPolling();
void pollSelectionsOdds();
oddsPollTimer = setInterval(() => {
void pollSelectionsOdds();
}, ODDS_POLL_MS);
}
function oddsDeltaFor(selectionId: string) {
return oddsDeltas.value[selectionId];
}
function oddsTrendClass(delta: OddsDelta) {
if (delta.suspended) return 'odds-suspended';
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
}
async function placeBet() {
if (!activeItems.value.length) return;
if (!auth.token) {
@@ -242,6 +365,13 @@ async function placeBet() {
: t('bet.parlay_need_more');
return;
}
if (hasSuspendedSelections.value) {
error.value = t('bet.odds_suspended');
return;
}
if (hasPendingOddsChanges.value) {
acceptPendingOdds();
}
loading.value = true;
error.value = '';
@@ -287,7 +417,10 @@ async function placeBet() {
watch(
() => props.modelValue,
(open) => {
if (!open) return;
if (!open) {
stopOddsPolling();
return;
}
activeTab.value = slip.mode;
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
activeTab.value = 'parlay';
@@ -297,9 +430,21 @@ watch(
success.value = '';
syncStakeInputFromSlip();
loadBalance();
startOddsPolling();
},
);
watch(
() => activeItems.value.map((item) => item.selectionId).join(','),
() => {
if (show.value) void pollSelectionsOdds();
},
);
onUnmounted(() => {
stopOddsPolling();
});
watch(
() => slip.mode,
(mode) => {
@@ -329,6 +474,8 @@ watch(
</button>
</div>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="slip-tabs">
<button
type="button"
@@ -362,7 +509,19 @@ watch(
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
</div>
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
<div class="item-odds">
<template v-if="oddsDeltaFor(slip.singleItem.selectionId)">
<span
class="odds-change"
:class="oddsTrendClass(oddsDeltaFor(slip.singleItem.selectionId)!)"
>
{{ oddsDeltaFor(slip.singleItem.selectionId)!.oldOdds.toFixed(2) }}
{{ oddsDeltaFor(slip.singleItem.selectionId)!.newOdds.toFixed(2) }}
</span>
</template>
<template v-else>{{ slip.singleItem.odds.toFixed(2) }}</template>
</div>
</div>
<p v-if="singleParlayOnlyHint" class="warning">{{ t('bet.slip_parlay_only_hint') }}</p>
@@ -379,7 +538,19 @@ watch(
<div class="item-pick">{{ item.selectionName }}</div>
</div>
<div class="item-side">
<strong>{{ item.odds.toFixed(2) }}</strong>
<strong>
<template v-if="oddsDeltaFor(item.selectionId)">
<span
class="odds-change"
:class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)"
>
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }}
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
</span>
</template>
<template v-else>{{ item.odds.toFixed(2) }}</template>
</strong>
<button type="button" class="remove" @click="removeItem(item.selectionId)">
{{ t('bet.slip_remove') }}
</button>
@@ -450,10 +621,10 @@ watch(
<button
type="button"
class="btn-primary"
:disabled="loading || !canSubmitActive"
:disabled="loading || !canSubmitWithOdds"
@click="placeBet"
>
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
{{ submitButtonLabel }}
</button>
</div>
</div>
@@ -704,6 +875,33 @@ watch(
line-height: 1;
}
.odds-warning {
margin: 0 16px 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(244, 162, 97, 0.12);
border: 1px solid rgba(244, 162, 97, 0.28);
color: var(--primary-light);
font-size: 12px;
line-height: 1.45;
}
.odds-change {
font-weight: 700;
}
.odds-change.odds-up {
color: #6ee7a0;
}
.odds-change.odds-down {
color: #ff8b8b;
}
.odds-change.odds-suspended {
color: #ffb84d;
}
.warning,
.error {
margin: 0 0 10px;

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = withDefaults(
defineProps<{
visible: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
loading?: boolean;
}>(),
{
danger: false,
loading: false,
},
);
const emit = defineEmits<{
'update:visible': [value: boolean];
confirm: [];
cancel: [];
}>();
const { t } = useI18n();
const resolvedTitle = computed(() => props.title ?? t('common.confirm'));
const resolvedConfirmText = computed(() => props.confirmText ?? t('common.confirm'));
const resolvedCancelText = computed(() => props.cancelText ?? t('common.cancel'));
function close() {
if (props.loading) return;
emit('update:visible', false);
emit('cancel');
}
function onConfirm() {
if (props.loading) return;
emit('confirm');
}
</script>
<template>
<Teleport to="body">
<Transition name="confirm-fade">
<div
v-if="visible"
class="confirm-overlay"
@click.self="close"
>
<div
class="confirm-modal"
role="alertdialog"
aria-modal="true"
:aria-labelledby="title ? 'confirm-dialog-title' : undefined"
:aria-describedby="'confirm-dialog-message'"
>
<h2 v-if="title" id="confirm-dialog-title" class="confirm-title">{{ resolvedTitle }}</h2>
<p id="confirm-dialog-message" class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<button
type="button"
class="confirm-btn cancel"
:disabled="loading"
@click="close"
>
{{ resolvedCancelText }}
</button>
<button
type="button"
class="confirm-btn confirm"
:class="{ danger }"
:disabled="loading"
@click="onConfirm"
>
{{ resolvedConfirmText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.confirm-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.confirm-modal {
width: 100%;
max-width: 340px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: 0 0 24px rgba(212, 175, 55, 0.08);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--gold, #c8a84e);
text-align: center;
line-height: 1.35;
}
.confirm-message {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: #c8c8c8;
text-align: center;
}
.confirm-actions {
display: flex;
gap: 10px;
}
.confirm-btn {
flex: 1;
min-height: 44px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s;
}
.confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirm-btn.cancel {
border: 1px solid #333;
background: transparent;
color: #888;
}
.confirm-btn.confirm {
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
}
.confirm-btn.confirm.danger {
background: linear-gradient(135deg, #c0392b, #e74c3c);
color: #fff;
}
.confirm-fade-enter-active,
.confirm-fade-leave-active {
transition: opacity 0.2s ease;
}
.confirm-fade-enter-active .confirm-modal,
.confirm-fade-leave-active .confirm-modal {
transition: transform 0.2s ease;
}
.confirm-fade-enter-from,
.confirm-fade-leave-to {
opacity: 0;
}
.confirm-fade-enter-from .confirm-modal,
.confirm-fade-leave-to .confirm-modal {
transform: scale(0.96);
}
</style>

View File

@@ -1,165 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildCustomerServiceUrl } from '../config/customerService';
import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const auth = useAuthStore();
const { profileRaw, avatarUrl } = usePlayerProfile();
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const iframeSrc = computed(() => {
const visitor = auth.user
? {
name:
profileRaw.value?.username ||
profileRaw.value?.preferences?.phone ||
auth.user.username ||
'',
avatar: avatarUrl.value
? new URL(avatarUrl.value, window.location.origin).href
: '',
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
}
: null;
return buildCustomerServiceUrl(t('support.connecting'), visitor);
});
function close() {
visible.value = false;
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="cs-overlay" @click.self="close">
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
<header class="cs-header">
<h2 class="cs-title">{{ t('support.title') }}</h2>
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
</button>
</header>
<div class="cs-body">
<iframe
v-if="visible"
:key="iframeSrc"
class="cs-frame"
:src="iframeSrc"
:title="t('support.title')"
allow="microphone; camera; clipboard-read; clipboard-write"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.cs-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.cs-modal {
display: flex;
flex-direction: column;
width: min(100%, 420px);
height: min(82vh, 680px);
background: #141414;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.cs-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #2a2a2a);
background: rgba(26, 26, 26, 0.98);
}
.cs-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-light, #c8a84e);
}
.close-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.close-btn:hover {
color: #aaa;
}
.cs-body {
flex: 1;
min-height: 0;
background: #0d0d0d;
}
.cs-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.cs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: 0;
padding: 24px;
text-align: center;
font-size: 14px;
line-height: 1.6;
color: #888;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildCustomerServiceUrl } from '../config/customerService';
import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { t } = useI18n();
const auth = useAuthStore();
const { profileRaw, avatarUrl } = usePlayerProfile();
const iframeSrc = computed(() => {
const visitor = auth.user
? {
name:
profileRaw.value?.username ||
profileRaw.value?.preferences?.phone ||
auth.user.username ||
'',
avatar: avatarUrl.value
? new URL(avatarUrl.value, window.location.origin).href
: '',
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
}
: null;
return buildCustomerServiceUrl(t('support.connecting'), visitor);
});
</script>
<template>
<div class="cs-panel">
<iframe
:key="iframeSrc"
class="cs-frame"
:src="iframeSrc"
:title="t('support.title')"
allow="microphone; camera; clipboard-read; clipboard-write"
/>
</div>
</template>
<style scoped>
.cs-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin: 0 -16px;
background: #0d0d0d;
}
.cs-frame {
flex: 1;
width: 100%;
min-height: calc(100dvh - 140px);
border: 0;
background: #fff;
}
</style>

View File

@@ -68,6 +68,7 @@ onUnmounted(() => {
<style scoped>
.locale-switch {
position: relative;
z-index: 120;
display: inline-flex;
flex-shrink: 0;
}
@@ -100,7 +101,7 @@ onUnmounted(() => {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 50;
z-index: 130;
min-width: 100%;
margin: 0;
padding: 4px;

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from './GoldSpinner.vue';
import ConfirmDialog from './ConfirmDialog.vue';
import { useAuthStore } from '../stores/auth';
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
const router = useRouter();
const { t, locale } = useI18n();
const auth = useAuthStore();
const {
messages,
loading,
listLoaded,
loadMessages,
refreshUnreadCount,
deleteMessage,
} = usePlayerMessages();
const page = ref(1);
const total = ref(0);
const deletingId = ref<string | null>(null);
const deleteConfirmVisible = ref(false);
const pendingDeleteId = ref<string | null>(null);
const hasMore = computed(() => messages.value.length < total.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');
return item.title;
}
function messagePreview(item: PlayerMessage) {
if (item.type === 'DEPOSIT_APPROVED' || item.type === 'DEPOSIT_REJECTED') {
const deposit = item.payload as DepositMessagePayload | null;
if (deposit?.orderNo) return deposit.orderNo;
}
return item.body.length > 80 ? `${item.body.slice(0, 80)}` : item.body;
}
function openDetail(id: string) {
router.push(`/messages/${id}`);
}
async function fetchPage(nextPage: number, append = false) {
const result = await loadMessages(nextPage, append);
if (result) {
page.value = result.page;
total.value = result.total;
}
}
function tryLoad() {
if (!auth.token) return;
void fetchPage(1);
void refreshUnreadCount();
}
function goLogin() {
auth.showLoginPrompt('/messages');
}
function onDelete(id: string, event: Event) {
event.stopPropagation();
if (deletingId.value) return;
pendingDeleteId.value = id;
deleteConfirmVisible.value = true;
}
function onDeleteCancel() {
pendingDeleteId.value = null;
}
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id || deletingId.value) return;
deletingId.value = id;
try {
await deleteMessage(id);
total.value = Math.max(0, total.value - 1);
deleteConfirmVisible.value = false;
pendingDeleteId.value = null;
} finally {
deletingId.value = null;
}
}
onMounted(tryLoad);
onActivated(tryLoad);
</script>
<template>
<div class="message-list-panel">
<div v-if="!auth.token" class="guest-hint">
<p>{{ t('auth.login_required') }}</p>
<button type="button" class="login-link" @click="goLogin">{{ t('auth.go_login') }}</button>
</div>
<template v-else>
<div v-if="loading && !listLoaded" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!messages.length" class="empty">
<p>{{ t('messages.empty') }}</p>
</div>
<div v-else class="list">
<div
v-for="item in messages"
:key="item.id"
class="list-row"
:class="{ unread: !item.isRead }"
>
<button type="button" class="row-body" @click="openDetail(item.id)">
<span class="row-dot" aria-hidden="true" />
<span class="row-main">
<span class="title-row">
<span class="title">{{ messageTitle(item) }}</span>
<span class="status-badge" :class="{ unread: !item.isRead }">
{{ item.isRead ? t('messages.status_read') : t('messages.status_unread') }}
</span>
</span>
<span class="preview">{{ messagePreview(item) }}</span>
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</button>
<button
type="button"
class="delete-btn"
:aria-label="t('messages.delete')"
:disabled="deletingId === item.id"
@click="onDelete(item.id, $event)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 7h12M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7h12Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<button v-if="hasMore" type="button" class="load-more" :disabled="loading" @click="fetchPage(page + 1, true)">
{{ loading ? t('common.loading_more') : t('messages.load_more') }}
</button>
</div>
</template>
<ConfirmDialog
v-model:visible="deleteConfirmVisible"
:title="t('messages.delete')"
:message="t('messages.delete_confirm')"
:confirm-text="t('messages.delete')"
danger
:loading="!!deletingId"
@confirm="confirmDelete"
@cancel="onDeleteCancel"
/>
</div>
</template>
<style scoped>
.message-list-panel {
flex: 1;
min-height: 0;
}
.guest-hint,
.state,
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted);
}
.login-link {
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;
}
.list {
display: flex;
flex-direction: column;
}
.list-row {
display: flex;
align-items: stretch;
gap: 0;
border-bottom: 1px solid var(--border);
}
.row-body {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 14px 0;
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.list-row.unread .title {
color: #fff;
font-weight: 700;
}
.row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: transparent;
flex-shrink: 0;
}
.list-row.unread .row-dot {
background: var(--gold);
box-shadow: 0 0 8px rgba(212, 175, 55, 0.45);
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.title {
flex: 1;
min-width: 0;
font-size: 15px;
color: #d8d8d8;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
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);
}
.preview {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
font-size: 12px;
color: #777;
}
.chevron {
color: var(--text-muted);
font-size: 20px;
flex-shrink: 0;
}
.delete-btn {
flex-shrink: 0;
align-self: center;
width: 36px;
height: 36px;
margin-left: 4px;
border: none;
border-radius: 8px;
background: transparent;
color: #666;
cursor: pointer;
}
.delete-btn:active:not(:disabled) {
background: rgba(255, 80, 80, 0.12);
color: #f66;
}
.delete-btn svg {
width: 18px;
height: 18px;
}
.delete-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.load-more {
margin-top: 12px;
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #141414;
color: var(--gold);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,174 @@
import api from '../api';
import { usePlayerProfile } from './usePlayerProfile';
import { usePlayerMessages } from './usePlayerMessages';
interface DepositOrderRow {
id: string;
orderNo: string;
amount: string;
status: string;
rejectReason?: string | null;
}
const POLL_FAST_MS = 8_000;
const POLL_SLOW_MS = 30_000;
const TRACKED_STORAGE_KEY = 'player_deposit_tracked_pending';
const lastStatus = new Map<string, string>();
const trackedPending = new Set<string>();
const notifiedKeys = new Set<string>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollingActive = false;
function notifyKey(orderId: string, type: 'approved' | 'rejected') {
return `${orderId}:${type}`;
}
function loadTrackedFromStorage() {
try {
const raw = sessionStorage.getItem(TRACKED_STORAGE_KEY);
if (!raw) return;
const ids: string[] = JSON.parse(raw);
for (const id of ids) {
if (id) trackedPending.add(String(id));
}
} catch {
/* ignore */
}
}
function persistTrackedToStorage() {
try {
sessionStorage.setItem(TRACKED_STORAGE_KEY, JSON.stringify([...trackedPending]));
} catch {
/* ignore */
}
}
function addTracked(orderId: string) {
trackedPending.add(orderId);
persistTrackedToStorage();
}
function removeTracked(orderId: string) {
if (!trackedPending.delete(orderId)) return;
persistTrackedToStorage();
}
function hasPendingInterest() {
return trackedPending.size > 0;
}
function schedulePoll(intervalMs: number) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
void pollOnce();
}, intervalMs);
}
function adjustPollInterval() {
if (!pollingActive) return;
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
}
function trackPendingOrder(orderId: string) {
const id = String(orderId);
if (!id) return;
addTracked(id);
lastStatus.set(id, 'PENDING');
adjustPollInterval();
void pollOnce();
}
function shouldNotify(orderId: string, prev: string | undefined, next: string) {
if (next !== 'APPROVED' && next !== 'REJECTED') return false;
if (prev === 'PENDING') return true;
if (trackedPending.has(orderId)) return true;
return false;
}
function handleStatusChange(order: DepositOrderRow): boolean {
const prev = lastStatus.get(order.id);
const next = order.status;
lastStatus.set(order.id, next);
if (next === 'PENDING') {
addTracked(order.id);
return false;
}
removeTracked(order.id);
if (!shouldNotify(order.id, prev, next)) return false;
const type = next === 'APPROVED' ? 'approved' : 'rejected';
const key = notifyKey(order.id, type);
if (notifiedKeys.has(key)) return false;
notifiedKeys.add(key);
void usePlayerMessages().refreshUnreadCount();
return next === 'APPROVED';
}
async function pollOnce() {
try {
const { data } = await api.get('/player/deposit-orders', { params: { page: 1 } });
const items: DepositOrderRow[] = data.data?.items ?? [];
const { refreshProfile } = usePlayerProfile();
let needsProfileRefresh = false;
for (const order of items) {
if (handleStatusChange(order)) needsProfileRefresh = true;
}
if (needsProfileRefresh) await refreshProfile();
adjustPollInterval();
} catch {
/* silent retry on next tick */
}
}
function onVisibilityChange() {
if (!document.hidden && pollingActive) void pollOnce();
}
function startPolling() {
if (pollingActive) {
void pollOnce();
return;
}
pollingActive = true;
loadTrackedFromStorage();
for (const id of trackedPending) {
if (!lastStatus.has(id)) lastStatus.set(id, 'PENDING');
}
document.addEventListener('visibilitychange', onVisibilityChange);
void pollOnce();
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
}
function stopPolling() {
pollingActive = false;
trackedPending.clear();
lastStatus.clear();
notifiedKeys.clear();
try {
sessionStorage.removeItem(TRACKED_STORAGE_KEY);
} catch {
/* ignore */
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
}
export function useDepositNotifications() {
return {
trackPendingOrder,
startPolling,
stopPolling,
pollOnce,
};
}

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { usePlayerHome } from './usePlayerHome';
/** 玩家端站内邮箱功能开关(来自 /player/home */
export function useInboxFeature() {
const { homeRaw } = usePlayerHome();
const inboxEnabled = computed(() => homeRaw.value?.inboxEnabled !== false);
const hubRoute = computed(() =>
inboxEnabled.value ? '/messages' : '/messages?tab=support',
);
const hubOpenLabelKey = computed(() =>
inboxEnabled.value ? 'inbox_hub.open' : 'inbox_hub.open_support',
);
const hubTitleKey = computed(() =>
inboxEnabled.value ? 'inbox_hub.title' : 'inbox_hub.tab_support',
);
return { inboxEnabled, hubRoute, hubOpenLabelKey, hubTitleKey };
}

View File

@@ -4,6 +4,7 @@ import api from '../api';
import type { BannerItem } from '../components/BannerCarousel.vue';
import { resolveBanners } from '../constants/defaultBanner';
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
import { stripHtml } from '../utils/html';
export interface PlayerHomeMatch {
id: string;
@@ -20,12 +21,52 @@ export interface PlayerHomeMatch {
displayOrder?: number;
}
export interface PlayerContentItem {
id: string;
contentType?: string;
sortOrder?: number;
createdAt?: string;
linkType?: string | null;
linkTarget?: string | null;
translation?: {
title?: string | null;
body?: string | null;
imageUrl?: string | null;
};
}
export type PlayerAnnouncementItem = PlayerContentItem;
interface HomePayload {
banners?: BannerItem[];
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
notices?: Array<{ translation?: { title?: string; body?: string } }>;
banners?: PlayerContentItem[];
announcements?: PlayerAnnouncementItem[];
ticker?: PlayerAnnouncementItem[];
notices?: PlayerAnnouncementItem[];
hotMatches?: PlayerHomeMatch[];
upcomingMatches?: PlayerHomeMatch[];
inboxEnabled?: boolean;
}
function mergeMatchList(
existing: PlayerHomeMatch[] | undefined,
fresh: PlayerHomeMatch[] | undefined,
): PlayerHomeMatch[] | undefined {
if (!fresh) return existing;
if (!existing) return fresh;
const freshMap = new Map(fresh.map((m) => [m.id, m]));
for (const m of existing) {
const f = freshMap.get(m.id);
if (f) Object.assign(m, f);
}
const existingIds = new Set(existing.map((m) => m.id));
for (const fm of fresh) {
if (!existingIds.has(fm.id)) existing.push(fm);
}
for (let i = existing.length - 1; i >= 0; i--) {
if (!freshMap.has(existing[i].id)) existing.splice(i, 1);
}
return existing;
}
const homeRaw = ref<HomePayload | null>(null);
@@ -40,12 +81,22 @@ function collectAnnouncementLines(data: HomePayload | null): string[] {
const lines: string[] = [];
for (const item of source) {
const text = item.translation?.title || item.translation?.body;
const title = item.translation?.title?.trim();
const text = title || stripHtml(item.translation?.body ?? '');
if (text) lines.push(text);
}
return lines;
}
function collectAnnouncementItems(data: HomePayload | null): PlayerAnnouncementItem[] {
if (!data) return [];
const source =
data.announcements && data.announcements.length > 0
? data.announcements
: [...(data.ticker ?? []), ...(data.notices ?? [])];
return source.filter((item) => item.translation?.title || item.translation?.body);
}
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
export function usePlayerHome() {
const { t } = useI18n();
@@ -64,26 +115,10 @@ export function usePlayerHome() {
existing.announcements = fresh.announcements;
existing.ticker = fresh.ticker;
existing.notices = fresh.notices;
existing.inboxEnabled = fresh.inboxEnabled;
if (fresh.hotMatches && existing.hotMatches) {
const freshMap = new Map(fresh.hotMatches.map((m) => [m.id, m]));
for (const m of existing.hotMatches) {
const f = freshMap.get(m.id);
if (f) Object.assign(m, f);
}
// 处理新增或删除的比赛
const existingIds = new Set(existing.hotMatches.map((m) => m.id));
for (const fm of fresh.hotMatches) {
if (!existingIds.has(fm.id)) existing.hotMatches.push(fm);
}
for (let i = existing.hotMatches.length - 1; i >= 0; i--) {
if (!freshMap.has(existing.hotMatches[i].id)) {
existing.hotMatches.splice(i, 1);
}
}
} else {
existing.hotMatches = fresh.hotMatches;
}
existing.hotMatches = mergeMatchList(existing.hotMatches, fresh.hotMatches);
existing.upcomingMatches = mergeMatchList(existing.upcomingMatches, fresh.upcomingMatches);
} else {
homeRaw.value = fresh;
}
@@ -94,18 +129,24 @@ export function usePlayerHome() {
}
}
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
const banners = computed(() => resolveBanners(homeRaw.value?.banners as BannerItem[] | undefined));
const bannerItems = computed(() => homeRaw.value?.banners ?? []);
const announcements = computed(() =>
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
);
const announcementItems = computed(() => collectAnnouncementItems(homeRaw.value));
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
const upcomingMatches = computed(() => homeRaw.value?.upcomingMatches ?? []);
return {
homeRaw,
loading,
load,
banners,
bannerItems,
announcements,
announcementItems,
hotMatches,
upcomingMatches,
};
}

View File

@@ -0,0 +1,130 @@
import { ref } from 'vue';
import api from '../api';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
export type DepositMessagePayload = {
depositOrderId?: string;
orderNo?: string;
amount?: string;
approvedAmount?: string | null;
rejectReason?: string | null;
};
export type BannerPromoPayload = {
contentId?: string;
};
export interface PlayerMessage {
id: string;
type: PlayerMessageType | string;
title: string;
body: string;
payload: DepositMessagePayload | BannerPromoPayload | null;
readAt: string | null;
createdAt: string;
isRead: boolean;
}
interface MessageListResponse {
items: PlayerMessage[];
total: number;
unreadCount: number;
page: number;
pageSize: number;
}
const unreadCount = ref(0);
const messages = ref<PlayerMessage[]>([]);
const loading = ref(false);
const listLoaded = ref(false);
async function refreshUnreadCount() {
try {
const { data } = await api.get('/player/messages/unread-count');
unreadCount.value = Number(data.data?.unreadCount ?? 0);
} catch {
/* silent */
}
}
async function loadMessages(page = 1, append = false) {
loading.value = true;
try {
const { data } = await api.get('/player/messages', { params: { page, pageSize: 20 } });
const payload = data.data as MessageListResponse;
const items = payload?.items ?? [];
messages.value = append ? [...messages.value, ...items] : items;
unreadCount.value = Number(payload?.unreadCount ?? unreadCount.value);
listLoaded.value = true;
return payload;
} finally {
loading.value = false;
}
}
async function loadMessageDetail(id: string) {
const { data } = await api.get(`/player/messages/${id}`);
return data.data as PlayerMessage;
}
async function markMessageRead(id: string) {
const { data } = await api.patch(`/player/messages/${id}/read`);
const updated = data.data as PlayerMessage;
messages.value = messages.value.map((item) =>
item.id === id ? { ...item, ...updated, isRead: true } : item,
);
if (unreadCount.value > 0) unreadCount.value -= 1;
return updated;
}
async function markAllRead() {
await api.patch('/player/messages/read-all');
messages.value = messages.value.map((item) => ({
...item,
isRead: true,
readAt: item.readAt ?? new Date().toISOString(),
}));
unreadCount.value = 0;
}
async function deleteMessage(id: string) {
const { data } = await api.delete(`/player/messages/${id}`);
const wasUnread = Boolean(data.data?.wasUnread);
messages.value = messages.value.filter((item) => item.id !== id);
if (wasUnread && unreadCount.value > 0) unreadCount.value -= 1;
}
async function deleteAllMessages() {
await api.delete('/player/messages');
messages.value = [];
unreadCount.value = 0;
listLoaded.value = true;
}
function resetMessagesState() {
unreadCount.value = 0;
messages.value = [];
listLoaded.value = false;
}
export function usePlayerMessages() {
return {
unreadCount,
messages,
loading,
listLoaded,
refreshUnreadCount,
loadMessages,
loadMessageDetail,
markMessageRead,
markAllRead,
deleteMessage,
deleteAllMessages,
resetMessagesState,
};
}

View File

@@ -0,0 +1,37 @@
import api from '../api';
const PING_INTERVAL_MS = 60_000;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let active = false;
async function sendPing() {
if (document.visibilityState !== 'visible') return;
try {
await api.post('/player/presence/ping');
} catch {
/* ignore transient network errors */
}
}
function onVisibilityChange() {
if (!active) return;
if (document.visibilityState === 'visible') void sendPing();
}
export function startPresencePing() {
if (active) return;
active = true;
void sendPing();
pingTimer = setInterval(() => void sendPing(), PING_INTERVAL_MS);
document.addEventListener('visibilitychange', onVisibilityChange);
}
export function stopPresencePing() {
active = false;
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
}

View File

@@ -7,8 +7,6 @@ const FALLBACK_BANNER_URL = '/uploads/banners/welcome.svg';
export const DEFAULT_BANNER: BannerItem = {
id: 'default',
linkType: 'ROUTE',
linkTarget: '/bet',
translation: {
title: '',
imageUrl: defaultBannerImg,

View File

@@ -8,11 +8,16 @@ export default {
load_failed: 'Failed to load',
retry: 'Retry',
back_to_top: 'Back to top',
cancel: 'Cancel',
confirm: 'Confirm',
},
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
home: {
hot_matches: 'Hot matches',
hot_tab: 'Hot',
upcoming_tab: 'Upcoming',
no_matches: 'No matches',
upcoming_empty: 'No matches kicking off in the next 3 days',
announcement_badge: 'Notice',
announcement_default:
'Welcome to TheBet365 · Football events are live · Bet responsibly',
@@ -21,6 +26,67 @@ export default {
banner_slide: 'Slide {n}',
banner_fallback: 'Banner',
},
announcements: {
title: 'Announcements',
detail_title: 'Announcement',
empty: 'No announcements yet',
not_found: 'This announcement is unavailable',
view_all: 'View all announcements',
type_notice: 'Notice',
type_ticker: 'Ticker',
type_banner: 'Banner',
related_link: 'Related link',
go_link: 'Go to page',
open_link: 'Open link',
back: 'Back',
},
search: {
placeholder: 'Search teams or leagues',
no_results: 'No matches found',
results_count: '{count} matches found',
hint: 'Enter a team or league to filter on the Bet page',
},
deposit_notify: {
approved_title: 'Deposit approved',
rejected_title: 'Deposit rejected',
view_history: 'View deposit history',
view_messages: 'Open inbox',
dismiss: 'Dismiss',
},
messages: {
title: 'Inbox',
detail_title: 'Message',
empty: 'No messages yet',
not_found: 'Message not found',
view_all: 'Back to inbox',
back: 'Back',
mark_all_read: 'Mark all read',
load_more: 'Load more',
delete: 'Delete',
delete_all: 'Delete all',
delete_confirm: 'Delete this message?',
delete_all_confirm: 'Delete all messages? This cannot be undone.',
banner_promo_view: 'View promotion',
content_promo_view: 'View details',
status_unread: 'Unread',
status_read: 'Read',
deposit_approved_title: 'Deposit approved',
deposit_rejected_title: 'Deposit rejected',
deposit_approved_body: 'Order {orderNo} approved. Requested {amount}, credited {approvedAmount}.',
deposit_rejected_body: 'Order {orderNo} ({amount}) was rejected. {reason}',
reject_reason: 'Rejection reason',
no_reason: 'No reason provided',
view_recharge_history: 'View recharge history',
unread_badge: '{count} unread',
open_inbox: 'Open inbox',
},
inbox_hub: {
title: 'Messages & Support',
tab_messages: 'Inbox',
tab_support: 'Support',
open: 'Open messages and support',
open_support: 'Open support',
},
history: {
league_default: 'Football',
stake: 'Stake',
@@ -396,6 +462,11 @@ export default {
slip_currency: 'Amount',
slip_min: 'Min',
slip_min_error: 'Minimum stake is {amount}',
odds_changed: 'Odds have changed on some selections. Accept to continue.',
odds_suspended: 'Some selections are suspended or closed. Remove them to continue.',
accept_changes_place: 'Accept changes & place bet',
odds_was: 'Was',
odds_now: 'Now',
place_success: 'Bet placed',
place_failed: 'Bet failed',
},

View File

@@ -8,6 +8,8 @@ export default {
load_failed: 'Gagal dimuat',
retry: 'Cuba lagi',
back_to_top: 'Kembali ke atas',
cancel: 'Batal',
confirm: 'Sahkan',
},
nav: {
home: 'Laman Utama',
@@ -18,7 +20,10 @@ export default {
},
home: {
hot_matches: 'Perlawanan popular',
hot_tab: 'Popular',
upcoming_tab: 'Terdekat',
no_matches: 'Tiada perlawanan',
upcoming_empty: 'Tiada perlawanan dalam 3 hari akan datang',
announcement_badge: 'Notis',
announcement_default:
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
@@ -27,6 +32,67 @@ export default {
banner_slide: 'Slaid {n}',
banner_fallback: 'Banner',
},
announcements: {
title: 'Pusat Pengumuman',
detail_title: 'Butiran Pengumuman',
empty: 'Tiada pengumuman',
not_found: 'Pengumuman tidak tersedia',
view_all: 'Lihat semua pengumuman',
type_notice: 'Notis',
type_ticker: 'Ticker',
type_banner: 'Banner',
related_link: 'Pautan berkaitan',
go_link: 'Pergi ke halaman',
open_link: 'Buka pautan',
back: 'Kembali',
},
search: {
placeholder: 'Cari pasukan atau liga',
no_results: 'Tiada perlawanan dijumpai',
results_count: '{count} perlawanan dijumpai',
hint: 'Masukkan pasukan atau liga untuk tapis di halaman Pertaruhan',
},
deposit_notify: {
approved_title: 'Deposit diluluskan',
rejected_title: 'Deposit ditolak',
view_history: 'Lihat sejarah deposit',
view_messages: 'Buka peti mesej',
dismiss: 'Tutup',
},
messages: {
title: 'Peti Mesej',
detail_title: 'Butiran Mesej',
empty: 'Tiada mesej',
not_found: 'Mesej tidak dijumpai',
view_all: 'Kembali ke peti mesej',
back: 'Kembali',
mark_all_read: 'Tanda semua dibaca',
load_more: 'Muat lagi',
delete: 'Padam',
delete_all: 'Padam semua',
delete_confirm: 'Padam mesej ini?',
delete_all_confirm: 'Padam semua mesej? Tindakan ini tidak boleh dibatalkan.',
banner_promo_view: 'Lihat promosi',
content_promo_view: 'Lihat butiran',
status_unread: 'Belum dibaca',
status_read: 'Dibaca',
deposit_approved_title: 'Deposit diluluskan',
deposit_rejected_title: 'Deposit ditolak',
deposit_approved_body: 'Pesanan {orderNo} diluluskan. Diminta {amount}, dikreditkan {approvedAmount}.',
deposit_rejected_body: 'Pesanan {orderNo} ({amount}) ditolak. {reason}',
reject_reason: 'Sebab penolakan',
no_reason: 'Tiada sebab diberikan',
view_recharge_history: 'Lihat sejarah deposit',
unread_badge: '{count} belum dibaca',
open_inbox: 'Buka peti mesej',
},
inbox_hub: {
title: 'Mesej & Sokongan',
tab_messages: 'Peti Mesej',
tab_support: 'Sokongan',
open: 'Buka mesej dan sokongan',
open_support: 'Buka sokongan',
},
history: {
league_default: 'Bola Sepak',
stake: 'Jumlah',
@@ -402,6 +468,11 @@ export default {
slip_currency: 'Amaun',
slip_min: 'Min',
slip_min_error: 'Jumlah minimum ialah {amount}',
odds_changed: 'Odds beberapa pilihan telah berubah. Terima untuk teruskan.',
odds_suspended: 'Beberapa pilihan digantung atau ditutup. Buang untuk teruskan.',
accept_changes_place: 'Terima perubahan & pertaruh',
odds_was: 'Asal',
odds_now: 'Baharu',
place_success: 'Pertaruhan berjaya',
place_failed: 'Pertaruhan gagal',
},

View File

@@ -8,11 +8,16 @@ export default {
load_failed: '加载失败',
retry: '重试',
back_to_top: '回到顶部',
cancel: '取消',
confirm: '确定',
},
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
home: {
hot_matches: '热门赛事',
hot_tab: '热门',
upcoming_tab: '近期',
no_matches: '暂无赛事',
upcoming_empty: '未来 3 天内暂无赛事',
announcement_badge: '公告',
announcement_default:
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
@@ -21,6 +26,67 @@ export default {
banner_slide: '第 {n} 张',
banner_fallback: 'Banner',
},
announcements: {
title: '公告中心',
detail_title: '公告详情',
empty: '暂无公告',
not_found: '公告不存在或已下线',
view_all: '查看全部公告',
type_notice: '公告',
type_ticker: '跑马灯',
type_banner: 'Banner',
related_link: '相关链接',
go_link: '前往页面',
open_link: '打开链接',
back: '返回',
},
search: {
placeholder: '搜索球队或联赛',
no_results: '未找到相关赛事',
results_count: '找到 {count} 场赛事',
hint: '输入球队或联赛名称,跳转至投注页筛选',
},
deposit_notify: {
approved_title: '充值已到账',
rejected_title: '充值未通过',
view_history: '查看充值记录',
view_messages: '查看消息中心',
dismiss: '关闭',
},
messages: {
title: '消息中心',
detail_title: '消息详情',
empty: '暂无消息',
not_found: '消息不存在',
view_all: '返回消息列表',
back: '返回',
mark_all_read: '全部已读',
load_more: '加载更多',
delete: '删除',
delete_all: '全部删除',
delete_confirm: '确定删除这条消息吗?',
delete_all_confirm: '确定删除全部消息吗?此操作不可恢复。',
banner_promo_view: '查看推广',
content_promo_view: '查看详情',
status_unread: '未读',
status_read: '已读',
deposit_approved_title: '充值已到账',
deposit_rejected_title: '充值未通过',
deposit_approved_body: '订单 {orderNo} 已审核通过,申请 {amount},到账 {approvedAmount}。',
deposit_rejected_body: '订单 {orderNo}{amount})未通过审核。{reason}',
reject_reason: '拒绝原因',
no_reason: '未提供原因',
view_recharge_history: '查看充值记录',
unread_badge: '{count} 条未读',
open_inbox: '打开消息中心',
},
inbox_hub: {
title: '消息与客服',
tab_messages: '邮箱',
tab_support: '客服',
open: '打开消息与客服',
open_support: '打开客服',
},
history: {
league_default: '足球',
stake: '投注',
@@ -235,7 +301,7 @@ export default {
audit_amount: '金额',
audit_credited: '入账金额',
audit_remark_label: '备注',
audit_summary: '审核记录 · {count} ',
audit_summary: '审核记录 · {count} ',
audit_toggle_show: '查看审核记录',
audit_toggle_hide: '收起审核记录',
view_detail: '查看详情',
@@ -396,6 +462,11 @@ export default {
slip_currency: '金额',
slip_min: '最低',
slip_min_error: '最低投注金额为 {amount}',
odds_changed: '部分选项赔率已变更,请确认后下注',
odds_suspended: '部分选项已暂停或关闭,请移除后重试',
accept_changes_place: '接受变更并下注',
odds_was: '原赔率',
odds_now: '新赔率',
place_success: '下注成功',
place_failed: '下注失败',
},

View File

@@ -13,17 +13,19 @@ import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
const CustomerServiceModal = defineAsyncComponent(
() => import('../components/CustomerServiceModal.vue'),
);
import { usePlayerHome } from '../composables/usePlayerHome';
import { useInboxFeature } from '../composables/useInboxFeature';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useDepositNotifications } from '../composables/useDepositNotifications';
import { usePlayerMessages } from '../composables/usePlayerMessages';
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
const { t, locale } = useI18n();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const route = useRoute();
const slip = useBetSlipStore();
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
const isDetailPage = computed(() => {
const p = route.path;
@@ -32,31 +34,40 @@ const isDetailPage = computed(() => {
p.startsWith('/bet/') ||
p.startsWith('/bets/') ||
p.startsWith('/wallet/') ||
p.startsWith('/messages') ||
p === '/profile/edit' ||
p === '/profile/cashbacks'
);
});
const showHeader = computed(() => !isDetailPage.value);
const showAnnouncement = computed(() => !isDetailPage.value && !route.path.startsWith('/profile'));
const showAnnouncement = computed(
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements'),
);
const showBottomNav = computed(() => {
const p = route.path;
if (
p === '/' ||
p === '/bet' ||
p === '/announcements' ||
p.startsWith('/announcements/') ||
p.startsWith('/match/') ||
p === '/bets' ||
p === '/wallet' ||
p === '/profile'
) return true;
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
if (!inboxEnabled.value && p === '/messages') return true;
return false;
});
const { announcements, load: loadPlayerHome } = usePlayerHome();
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
const { startPolling, stopPolling } = useDepositNotifications();
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
const mainRef = ref<HTMLElement | null>(null);
const tabScrollTops = new Map<string, number>();
const customerServiceOpen = ref(false);
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
@@ -87,6 +98,13 @@ watch(
// 个人资料仅登录用户需要
if (token) {
void loadProfile(true);
startPolling();
startPresencePing();
if (inboxEnabled.value) void refreshUnreadCount();
} else {
stopPolling();
stopPresencePing();
resetMessagesState();
}
},
{ immediate: true },
@@ -96,6 +114,9 @@ watch(
() => route.path,
(path) => {
if (!auth.token) return;
if (inboxEnabled.value && path.startsWith('/messages')) {
void refreshUnreadCount();
}
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
void refreshProfile();
}
@@ -108,13 +129,12 @@ watch(
<header v-if="showHeader" class="header">
<img src="/logo.png" alt="TheBet365" class="logo" />
<div class="header-actions">
<button
type="button"
class="support-btn"
:aria-label="t('support.open')"
@click="customerServiceOpen = true"
<RouterLink
:to="hubRoute"
class="hub-btn"
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
>
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
fill="none"
@@ -126,8 +146,8 @@ watch(
<circle cx="12" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="11" r="1" fill="currentColor" />
</svg>
<span class="support-label">{{ t('support.short') }}</span>
</button>
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="hub-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</RouterLink>
<LocaleSwitcher />
<template v-if="auth.user">
<CashBalanceChip />
@@ -140,7 +160,11 @@ watch(
</header>
<div v-if="showAnnouncement" class="announce-strip">
<AnnouncementMarquee :items="announcements" embedded />
<AnnouncementMarquee
:items="announcements"
:target-id="primaryAnnouncementId"
embedded
/>
</div>
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
@@ -182,7 +206,6 @@ watch(
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BetSlipDrawer v-model="slip.drawerOpen" />
<CustomerServiceModal v-model="customerServiceOpen" />
</div>
</template>
@@ -197,11 +220,12 @@ watch(
}
.header {
flex-shrink: 0;
position: relative;
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px;
background: rgba(17, 17, 17, 0.94);
border-bottom: 1px solid var(--border);
z-index: 110;
z-index: 120;
}
.logo {
height: 36px; width: auto; display: block;
@@ -224,39 +248,45 @@ watch(
width: var(--header-chip-h);
}
.support-btn {
.hub-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
justify-content: center;
width: var(--header-chip-h, 36px);
height: var(--header-chip-h, 36px);
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
background: rgba(200, 168, 78, 0.08);
color: var(--primary-light, #c8a84e);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 1;
min-width: 0;
flex-shrink: 0;
text-decoration: none;
}
.support-btn:active {
.hub-btn:active {
background: rgba(200, 168, 78, 0.16);
}
.support-icon {
.hub-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.support-label {
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hub-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px rgba(17, 17, 17, 0.94);
}
.login-btn {
@@ -276,7 +306,8 @@ watch(
}
.announce-strip {
flex-shrink: 0;
z-index: 105;
position: relative;
z-index: 100;
border-bottom: 1px solid var(--border);
}

View File

@@ -15,6 +15,10 @@ const router = createRouter({
{ path: '', component: () => import('../views/HomeView.vue'), meta: { keepAlive: true } },
{ path: 'bet', component: () => import('../views/FootballView.vue'), meta: { keepAlive: true } },
{ path: 'football', redirect: '/bet' },
{ path: 'announcements', component: () => import('../views/AnnouncementListView.vue') },
{ path: 'announcements/:id', component: () => import('../views/AnnouncementDetailView.vue') },
{ path: 'messages', component: () => import('../views/InboxHubView.vue'), meta: { keepAlive: true, requiresAuth: false } },
{ path: 'messages/:id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
// 需要登录的页面
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true, requiresAuth: true } },

View File

@@ -179,6 +179,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
drawerOpen.value = false;
}
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
if (singleItem.value?.selectionId === selectionId) {
singleItem.value = { ...singleItem.value, odds, oddsVersion };
}
const idx = parlayItems.value.findIndex((i) => i.selectionId === selectionId);
if (idx >= 0) {
parlayItems.value[idx] = { ...parlayItems.value[idx], odds, oddsVersion };
}
}
return {
singleItem,
parlayItems,
@@ -209,5 +219,6 @@ export const useBetSlipStore = defineStore('betSlip', () => {
clearAll,
openDrawer,
closeDrawer,
updateSelectionOdds,
};
});

View File

@@ -0,0 +1,65 @@
const ALLOWED_TAGS = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
]);
function sanitizeNode(node: Node): Node | null {
if (node.nodeType === Node.TEXT_NODE) {
return node.cloneNode(false);
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
const frag = document.createDocumentFragment();
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) frag.appendChild(safe);
}
return frag;
}
const out = document.createElement(tag);
if (tag === 'img') {
const src = el.getAttribute('src')?.trim();
if (!src || /^javascript:/i.test(src)) return null;
out.setAttribute('src', src);
const alt = el.getAttribute('alt');
if (alt) out.setAttribute('alt', alt);
return out;
}
if (tag === 'a') {
const href = el.getAttribute('href')?.trim();
if (!href || /^javascript:/i.test(href)) return null;
out.setAttribute('href', href);
out.setAttribute('target', '_blank');
out.setAttribute('rel', 'noopener noreferrer');
}
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) out.appendChild(safe);
}
return out;
}
/** 玩家端公告正文 HTML 白名单净化 */
export function sanitizeAnnouncementHtml(html: string): string {
if (!html?.trim()) return '';
if (!/[<>]/.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
const container = document.createElement('div');
for (const child of Array.from(doc.body.childNodes)) {
const safe = sanitizeNode(child);
if (safe) container.appendChild(safe);
}
return container.innerHTML;
}
export function stripHtml(html: string): string {
if (!html) return '';
if (!/[<>]/.test(html)) return html.trim();
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed, onActivated, 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 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 { announcementItems, bannerItems, loading, load } = usePlayerHome();
const announcementId = computed(() => String(route.params.id ?? ''));
const item = computed<PlayerContentItem | null>(() => {
const id = announcementId.value;
return (
bannerItems.value.find((entry) => entry.id === id) ??
announcementItems.value.find((entry) => entry.id === id) ??
null
);
});
const isBanner = computed(() => item.value?.contentType === 'BANNER');
function goBack() {
router.back();
}
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">
<header class="page-header">
<button type="button" class="back-btn" :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;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
margin-bottom: 0;
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;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.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;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: #080808;
line-height: 0;
}
.detail-hero img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
object-position: center;
}
.detail-body-wrap {
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;
}
.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;
}
.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>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { stripHtml } from '../utils/html';
const router = useRouter();
const { t, locale } = useI18n();
const { announcementItems, loading, load } = usePlayerHome();
const items = computed(() => announcementItems.value);
function goBack() {
router.back();
}
function openDetail(id: string) {
router.push(`/announcements/${id}`);
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function itemTitle(item: (typeof items.value)[number]) {
const title = item.translation?.title?.trim();
if (title) return title;
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
}
function itemPreview(item: (typeof items.value)[number]) {
const title = item.translation?.title?.trim();
const bodyText = stripHtml(item.translation?.body ?? '');
if (bodyText && bodyText !== title) return bodyText;
return '';
}
onActivated(() => {
void load(true);
});
</script>
<template>
<div class="announce-page">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.title') }}</h1>
</header>
<div v-if="loading && !items.length" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!items.length" class="empty">
<p>{{ t('announcements.empty') }}</p>
</div>
<div v-else class="list">
<button
v-for="item in items"
:key="item.id"
type="button"
class="list-row"
@click="openDetail(item.id)"
>
<span class="row-main">
<span class="title">{{ itemTitle(item) }}</span>
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.announce-page {
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);
margin-bottom: 0;
}
.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;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.state,
.empty {
display: flex;
justify-content: center;
padding: 48px 16px;
color: var(--text-muted);
}
.list {
display: flex;
flex-direction: column;
}
.list-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
text-align: left;
color: inherit;
cursor: pointer;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 15px;
font-weight: 600;
color: #fff;
line-height: 1.4;
}
.preview {
display: block;
margin-top: 6px;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.chevron {
flex-shrink: 0;
font-size: 20px;
color: var(--text-muted);
line-height: 1;
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { usePlayerMatches } from '../composables/usePlayerMatches';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import MatchBetCard from '../components/MatchBetCard.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
@@ -59,8 +60,10 @@ interface LeagueGroup {
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const mainTab = ref<MainTab>('matches');
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
@@ -96,10 +99,23 @@ function normalizeLeagueName(name: string): string {
.trim();
}
function syncSearchFromRoute() {
const q = route.query.search;
searchQuery.value = typeof q === 'string' ? q : '';
}
function matchesSearchKeyword(m: Match, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
const keyword = searchQuery.value.trim().toLowerCase();
return matches.value.filter((m) => {
if (!matchesSearchKeyword(m, keyword)) return false;
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
@@ -196,6 +212,16 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
const isSearchActive = computed(() => searchQuery.value.trim().length > 0);
const sortedFilteredMatches = computed(() =>
[...filteredMatches.value].sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
),
);
const expandedLeagues = ref(new Set<string>());
watch(leagueGroups, (groups) => {
@@ -226,9 +252,15 @@ function selectMainTab(tab: MainTab) {
onActivated(() => {
filterNow.value = new Date();
filterState.value.time = 'all';
syncSearchFromRoute();
void loadSummary(true, true);
});
watch(
() => route.query.search,
() => syncSearchFromRoute(),
);
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -265,6 +297,17 @@ function goMatch(id: string) {
</div>
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="search-bar">
<span class="search-icon" aria-hidden="true"></span>
<input
v-model="searchQuery"
type="search"
:placeholder="t('search.placeholder')"
enterkeyhint="search"
autocomplete="off"
/>
</div>
<div class="filters-bar">
<div class="phase-filter">
<div class="league-dropdown">
@@ -340,7 +383,22 @@ function goMatch(id: string) {
</div>
<template v-else>
<div>
<div v-if="leagueGroups.length" class="league-list">
<div v-if="isSearchActive && sortedFilteredMatches.length" class="search-results">
<p class="search-results-meta">
{{ t('search.results_count', { count: sortedFilteredMatches.length }) }}
</p>
<div class="search-match-list">
<article
v-for="match in sortedFilteredMatches"
:key="match.id"
class="search-result-item"
>
<p class="search-result-league">{{ normalizeLeagueName(match.leagueName) }}</p>
<MatchBetCard :match="match" @bet="goMatch" />
</article>
</div>
</div>
<div v-else-if="!isSearchActive && leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in leagueGroups"
:key="group.leagueId"
@@ -355,7 +413,7 @@ function goMatch(id: string) {
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
<p>{{ isSearchActive ? t('search.no_results') : t('bet.no_matches') }}</p>
</div>
</div>
</template>
@@ -441,6 +499,45 @@ function goMatch(id: string) {
line-height: 1;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 8px;
padding: 0 10px;
height: 32px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
}
.search-icon {
color: var(--gold);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.filters-bar {
display: flex;
align-items: center;
@@ -630,6 +727,38 @@ function goMatch(id: string) {
padding: 4px 12px 0;
}
.search-results {
padding: 0 12px;
}
.search-results-meta {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.search-match-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-result-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-result-league {
margin: 0;
padding: 0 2px;
font-size: 11px;
font-weight: 700;
color: var(--primary-light);
line-height: 1.3;
}
.state,
.placeholder,
.empty {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onActivated } from 'vue';
import { onActivated, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
@@ -11,11 +11,28 @@ import TeamEmblem from '../components/TeamEmblem.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import type { PlayerHomeMatch } from '../composables/usePlayerHome';
type HotTab = 'hot' | 'upcoming';
const matchCardBg = `url(${cardBg})`;
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, loading, load } = usePlayerHome();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeTab = ref<HotTab>('hot');
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
@@ -47,14 +64,35 @@ function formatKickoff(startTime: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<BannerCarousel :banners="banners" />
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
>
{{ t('home.hot_tab') }}
</button>
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') }}
</button>
</div>
<div
v-for="(match, index) in hotMatches"
v-for="(match, index) in displayedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
@@ -102,9 +140,9 @@ function formatKickoff(startTime: string) {
</div>
</div>
<div v-if="!loading && !hotMatches.length" class="empty">
<div v-if="!loading && !displayedMatches.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('home.no_matches') }}</p>
<p>{{ emptyMessage }}</p>
</div>
</div>
</template>
@@ -118,6 +156,36 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-tabs {
display: flex;
gap: 8px;
margin: 14px 0;
}
.hot-tab {
flex: 1;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.hot-tab.active {
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.hot-tab:active {
opacity: 0.92;
}
.match-card {
position: relative;
isolation: isolate;

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import MessageListPanel from '../components/MessageListPanel.vue';
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { useAuthStore } from '../stores/auth';
import { usePlayerMessages } from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
type HubTab = 'messages' | 'support';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const auth = useAuthStore();
const { inboxEnabled, hubTitleKey } = useInboxFeature();
const {
unreadCount,
messages,
listLoaded,
refreshUnreadCount,
markAllRead,
deleteAllMessages,
} = usePlayerMessages();
const markingAll = ref(false);
const deletingAll = ref(false);
const deleteAllConfirmVisible = ref(false);
const activeTab = computed<HubTab>(() => {
if (!inboxEnabled.value) return 'support';
return route.query.tab === 'support' ? 'support' : 'messages';
});
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
const showMessageActions = computed(
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
);
function switchTab(tab: HubTab) {
if (!inboxEnabled.value || tab === activeTab.value) return;
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
}
async function onMarkAllRead() {
if (!unreadInList.value || markingAll.value) return;
markingAll.value = true;
try {
await markAllRead();
await refreshUnreadCount();
} finally {
markingAll.value = false;
}
}
function onDeleteAll() {
if (deletingAll.value || !messages.value.length) return;
deleteAllConfirmVisible.value = true;
}
async function confirmDeleteAll() {
if (deletingAll.value || !messages.value.length) return;
deletingAll.value = true;
try {
await deleteAllMessages();
await refreshUnreadCount();
deleteAllConfirmVisible.value = false;
} finally {
deletingAll.value = false;
}
}
function ensureSupportTabWhenDisabled() {
if (inboxEnabled.value || route.path !== '/messages') return;
if (route.query.tab === 'support') return;
void router.replace({ path: '/messages', query: { tab: 'support' } });
}
function goBack() {
router.back();
}
watch(inboxEnabled, (enabled, prev) => {
if (prev && !enabled) ensureSupportTabWhenDisabled();
});
watch(
() => route.query.tab,
() => {
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
},
);
onMounted(ensureSupportTabWhenDisabled);
onActivated(() => {
if (inboxEnabled.value) {
void refreshUnreadCount();
return;
}
ensureSupportTabWhenDisabled();
});
</script>
<template>
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
<header v-if="!inboxEnabled" class="page-header">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1>{{ t(hubTitleKey) }}</h1>
</header>
<div v-if="inboxEnabled" class="hub-top-bar">
<button type="button" class="hub-back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<nav class="hub-tabs" role="tablist">
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'messages' }"
:aria-selected="activeTab === 'messages'"
@click="switchTab('messages')"
>
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'support' }"
:aria-selected="activeTab === 'support'"
@click="switchTab('support')"
>
{{ t('inbox_hub.tab_support') }}
</button>
</nav>
</div>
<div v-if="showMessageActions" class="message-actions">
<button
type="button"
class="action-btn"
:disabled="!unreadInList || markingAll"
@click="onMarkAllRead"
>
{{ t('messages.mark_all_read') }}
</button>
<button
type="button"
class="action-btn danger"
:disabled="deletingAll"
@click="onDeleteAll"
>
{{ t('messages.delete_all') }}
</button>
</div>
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
<CustomerServicePanel v-if="activeTab === 'support'" />
<ConfirmDialog
v-model:visible="deleteAllConfirmVisible"
:title="t('messages.delete_all')"
:message="t('messages.delete_all_confirm')"
:confirm-text="t('messages.delete_all')"
danger
:loading="deletingAll"
@confirm="confirmDeleteAll"
/>
</div>
</template>
<style scoped>
.inbox-hub {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 0 0 24px;
}
.inbox-hub--tabs {
margin: -12px -16px 0;
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.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;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.hub-top-bar {
display: flex;
align-items: stretch;
gap: 6px;
margin: 0;
border-bottom: 1px solid var(--border);
}
.hub-back-btn {
flex-shrink: 0;
align-self: center;
width: 32px;
height: 32px;
border: 1px solid var(--border);
border-radius: 8px;
background: #141414;
color: var(--gold);
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.hub-tabs {
flex: 1;
display: flex;
gap: 0;
margin: 0;
min-width: 0;
}
.hub-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.hub-tab.active {
color: var(--gold);
border-bottom-color: var(--gold);
}
.hub-tab-label {
line-height: 1.2;
}
.hub-tab-badge {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--gold);
color: #111;
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-actions {
display: flex;
gap: 8px;
padding: 10px 0 4px;
}
.action-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-gold-soft);
border-radius: 8px;
background: rgba(212, 175, 55, 0.08);
color: var(--gold);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.action-btn.danger {
border-color: rgba(255, 80, 80, 0.35);
background: rgba(255, 80, 80, 0.08);
color: #f88;
}
</style>

View File

@@ -0,0 +1,360 @@
<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 { formatMoney } from '../utils/localeDisplay';
import {
usePlayerMessages,
type DepositMessagePayload,
type BannerPromoPayload,
type PlayerMessage,
} from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
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();
});
</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>
<p 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>
</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;
}
.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>

View File

@@ -8,6 +8,7 @@ import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useInboxFeature } from '../composables/useInboxFeature';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
@@ -18,6 +19,7 @@ const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const { hubRoute } = useInboxFeature();
const loading = ref(true);
const error = ref(false);
@@ -186,6 +188,17 @@ const balanceAmountClass = computed(() => {
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink :to="hubRoute" class="settings-cell settings-cell--gold-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
<path d="m4 7 8 5.5L20 7" />
</svg>
<span class="cell-label">{{ t('messages.title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--gold-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">

View File

@@ -12,9 +12,11 @@ import {
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../utils/depositAuditDisplay';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const { t, locale } = useI18n();
const { trackPendingOrder, pollOnce } = useDepositNotifications();
interface DepositAuditLog {
id: string;
@@ -66,6 +68,10 @@ async function fetchOrders(p = 1) {
if (p === 1) {
items.value = newItems;
for (const order of newItems) {
if (order.status === 'PENDING') trackPendingOrder(order.id);
}
void pollOnce();
} else {
items.value = [...items.value, ...newItems];
}
@@ -316,10 +322,10 @@ function auditStepCount(order: DepositOrder) {
<Teleport to="body">
<div v-if="selectedOrder" class="detail-overlay" @click.self="closeDetail">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.order_detail')">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.audit_title')">
<button type="button" class="detail-close" :aria-label="t('common.close')" @click="closeDetail"></button>
<h3 class="detail-title">{{ t('recharge.order_detail') }}</h3>
<h3 class="detail-title">{{ t('recharge.audit_title') }}</h3>
<div class="detail-summary">
<div class="detail-summary-head">
@@ -409,7 +415,7 @@ function auditStepCount(order: DepositOrder) {
</template>
<style scoped>
.recharge-history-page { padding: 0 16px 24px; }
.recharge-history-page { padding: 0 0 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }

View File

@@ -5,10 +5,12 @@ import { useI18n } from 'vue-i18n';
import imageCompression from 'browser-image-compression';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { trackPendingOrder } = useDepositNotifications();
const reapplyOrderId = computed(() => {
const id = route.query.orderId;
@@ -186,6 +188,7 @@ async function handleSubmit() {
const { data } = await api.post(`/player/deposit-orders/${reapplyOrderId.value}/reapply`, fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
return;
}
@@ -194,6 +197,7 @@ async function handleSubmit() {
const { data } = await api.post('/player/deposit-orders', fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
} catch (e: any) {
alert(e.response?.data?.message || t('recharge.submit_failed'));
@@ -362,7 +366,7 @@ onMounted(fetchMethods);
</template>
<style scoped>
.recharge-page { padding: 0 12px 24px; }
.recharge-page { padding: 0 0 24px; }
.page-header {
display: flex; align-items: center; justify-content: space-between;