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

@@ -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,
};
}