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

@@ -9,6 +9,7 @@ import { AdminPerm } from '../constants/permissions';
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
import AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
import { useDepositPendingCount } from '../composables/useDepositPendingCount';
const route = useRoute();
const router = useRouter();
@@ -16,11 +17,18 @@ const auth = useAuthStore();
const { t } = useAdminLocale();
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
const { hasPermission, role: adminRole } = usePermissions();
const { pendingCount: depositPendingCount, startDepositPendingPolling, stopDepositPendingPolling } =
useDepositPendingCount();
const canSeeDepositPending = computed(
() => auth.isAdmin.value && hasPermission(AdminPerm.depositReview, AdminPerm.depositManage),
);
const sidebarOpen = ref(false);
const isMobileNav = ref(false);
type AdminMenuItem = {
key: string;
path: string;
label: string;
icon: string;
@@ -34,23 +42,30 @@ function menuVisible(item: AdminMenuItem): boolean {
const code = adminRole.value;
if (item.path === '/smoke-tests' && smokeTestsAllowed.value === false) return false;
if (code && code !== 'SUPER_ADMIN' && item.excludeRoles?.includes(code)) return false;
return hasPermission(...item.permissions);
if (!hasPermission(...item.permissions)) return false;
const visible = auth.user.value?.visibleMenus;
if (visible) {
const list = visible.split(',');
return list.includes(item.key);
}
return true;
}
const adminMenus = computed(() => {
const items: AdminMenuItem[] = [
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
{ path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
{ path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
{ path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
{ key: 'dashboard', path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
{ key: 'matches', path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
{ key: 'users', path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
{ key: 'finance-logs', path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
{ key: 'deposit', path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
{ key: 'cashback', path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
{ key: 'bets', path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
{ key: 'contents', path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
{ key: 'media', path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
{ key: 'audit', path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
{ key: 'staff', path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
{ key: 'smoke-tests', path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
];
return items.filter(menuVisible);
});
@@ -149,11 +164,13 @@ onMounted(() => {
window.addEventListener('resize', syncMobileNav);
if (auth.isAdmin.value) {
void ensureSmokeTestsAllowed();
if (canSeeDepositPending.value) startDepositPendingPolling();
}
});
onUnmounted(() => {
window.removeEventListener('resize', syncMobileNav);
stopDepositPendingPolling();
});
watch(() => route.path, () => {
@@ -177,6 +194,7 @@ watch(() => route.path, () => {
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="brand">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<span class="brand-title">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</span>
</div>
<nav class="nav">
@@ -197,7 +215,16 @@ watch(() => route.path, () => {
@click="onNavClick"
>
<AdminNavIcon :name="m.icon" />
<span class="nav-label">{{ m.label }}</span>
<span class="nav-label">
{{ m.label }}
<span
v-if="'path' in m && m.path === '/deposit' && depositPendingCount > 0"
class="nav-pending-badge"
:title="t('deposit.pending_badge', { n: depositPendingCount })"
>
{{ depositPendingCount > 99 ? '99+' : depositPendingCount }}
</span>
</span>
</RouterLink>
</nav>
@@ -245,7 +272,6 @@ watch(() => route.path, () => {
</div>
</div>
<AdminLocaleSwitcher />
<div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
</div>
</header>
@@ -285,17 +311,26 @@ watch(() => route.path, () => {
border-bottom: 1px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 8px;
flex-shrink: 0;
box-sizing: border-box;
}
.brand-logo {
max-width: 118px;
max-height: 34px;
max-width: 52px;
max-height: 32px;
width: auto;
height: auto;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.brand-title {
font-size: 12px;
font-weight: 700;
line-height: 1.25;
color: #f0f0f0;
letter-spacing: 0.02em;
}
.nav {
@@ -324,8 +359,26 @@ watch(() => route.path, () => {
transition: opacity 0.15s;
}
.nav-label {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
line-height: 1.25;
flex: 1;
}
.nav-pending-badge {
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.25);
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
@@ -584,14 +637,23 @@ watch(() => route.path, () => {
.brand {
height: 64px;
min-height: 64px;
padding: 0 16px;
padding: 0 12px;
border-bottom: 1px solid var(--border-soft);
justify-content: flex-start;
gap: 10px;
}
.brand-logo {
max-width: 132px;
max-height: 38px;
max-width: 56px;
max-height: 34px;
}
.brand-title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
color: #2a2824;
letter-spacing: 0.02em;
}
.nav {