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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user