feat(theme-2): sync inbox/announcements/presence/deposit from main
This commit is contained in:
174
apps/player/src/composables/useDepositNotifications.ts
Normal file
174
apps/player/src/composables/useDepositNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
23
apps/player/src/composables/useInboxFeature.ts
Normal file
23
apps/player/src/composables/useInboxFeature.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
130
apps/player/src/composables/usePlayerMessages.ts
Normal file
130
apps/player/src/composables/usePlayerMessages.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
37
apps/player/src/composables/usePresencePing.ts
Normal file
37
apps/player/src/composables/usePresencePing.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user