Files
thebet365/apps/player/src/views/FootballView.vue
Mars f9343b00af 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>
2026-06-17 17:51:10 +08:00

785 lines
19 KiB
Vue

<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
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';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import type { MatchPhase } from '../utils/matchPhase';
import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
type MainTab = 'matches' | 'outright';
interface Match {
id: string;
leagueId?: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
leagueName: string;
leagueLogoUrl?: string | null;
displayOrder?: number;
isHot?: boolean;
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
score?: {
htHome: number | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
}
interface LeagueGroup {
leagueId: string;
leagueName: string;
leagueLogoUrl?: string | null;
matches: Match[];
}
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',
leagueIds: [] as string[]
});
const outrightActivated = ref(false);
const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
async function loadMatches() {
filterNow.value = new Date();
await loadSummary(true);
}
useOnLocaleChange(() => loadSummary(true));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await loadMatches(); },
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.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);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) return false;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') return false;
if (filterState.value.leagueIds.length > 0) {
const id = m.leagueId ?? m.leagueName;
if (!filterState.value.leagueIds.includes(id)) return false;
}
return true;
});
});
const availableLeagues = computed(() => {
const map = new Map<string, { id: string, name: string }>();
for (const m of matches.value) {
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, filterNow.value);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, filterNow.value);
}
if (!timeMatch) continue;
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { id, name: normalizeLeagueName(m.leagueName) });
}
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
});
watch(() => filterState.value.time, () => {
filterState.value.leagueIds = [];
});
const dropdownOpen = ref(false);
const selectedLeagueName = computed(() => {
if (filterState.value.leagueIds.length === 0) return '';
const firstId = filterState.value.leagueIds[0];
const lg = availableLeagues.value.find(l => l.id === firstId);
return lg ? lg.name : '';
});
function selectLeague(id: string) {
if (id === '') {
filterState.value.leagueIds = [];
} else {
filterState.value.leagueIds = [id];
}
dropdownOpen.value = false;
}
function toggleStatusOpen() {
filterState.value.status = filterState.value.status === 'open' ? 'all' : 'open';
}
function buildLeagueGroups(source: Match[]): LeagueGroup[] {
const map = new Map<string, LeagueGroup>();
for (const m of source) {
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, {
leagueId: id,
leagueName: normalizeLeagueName(m.leagueName),
leagueLogoUrl: m.leagueLogoUrl ?? null,
matches: [],
});
}
map.get(id)!.matches.push(m);
}
const groups = [...map.values()];
for (const g of groups) {
g.matches.sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
);
}
return groups.sort(
(a, b) =>
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
a.leagueName.localeCompare(b.leagueName),
);
}
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) => {
const ids = new Set(expandedLeagues.value);
for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (groups.length > 0 && ids.size === 0) {
ids.add(groups[0].leagueId);
}
if (ids.size !== expandedLeagues.value.size || [...ids].some((id) => !expandedLeagues.value.has(id))) {
expandedLeagues.value = ids;
}
});
function toggleLeague(leagueId: string) {
const next = new Set(expandedLeagues.value);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = next;
}
function selectMainTab(tab: MainTab) {
mainTab.value = tab;
if (tab === 'outright') outrightActivated.value = true;
}
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}`);
}
</script>
<template>
<div class="bet-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div class="main-tabs">
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
@click="selectMainTab('matches')"
>
<span class="tab-icon"></span>
{{ t('bet.tab_matches') }}
</button>
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
@click="selectMainTab('outright')"
>
<span class="tab-icon">🏆</span>
{{ t('bet.tab_outright') }}
</button>
</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">
<button type="button" class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-text">{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
<span class="arrow-icon" :class="{ open: dropdownOpen }"></span>
</button>
<div v-if="dropdownOpen" class="dropdown-backdrop" @click="dropdownOpen = false"></div>
<div v-if="dropdownOpen" class="dropdown-menu">
<div
class="dropdown-item"
:class="{ active: filterState.leagueIds.length === 0 }"
@click="selectLeague('')"
>
{{ t('bet.filter_leagues_all') }}
</div>
<div
v-for="lg in availableLeagues"
:key="lg.id"
class="dropdown-item"
:class="{ active: filterState.leagueIds.includes(lg.id) }"
@click="selectLeague(lg.id)"
>
{{ lg.name }}
</div>
</div>
</div>
</div>
<div class="right-filters">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'all' }"
@click="filterState.time = 'all'"
>
{{ t('bet.filter_status_all') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'today' }"
@click="filterState.time = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'early' }"
@click="filterState.time = 'early'"
>
{{ t('bet.tab_early') }}
</button>
</div>
<div class="divider"></div>
<button
type="button"
class="status-toggle-btn"
:class="{ active: filterState.status === 'open' }"
@click="toggleStatusOpen"
>
{{ t('bet.filter_status_open') }}
</button>
</div>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div>
<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"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ isSearchActive ? t('search.no_results') : t('bet.no_matches') }}</p>
</div>
</div>
</template>
</div>
<OutrightPanel
v-if="outrightActivated"
:class="['outright-tab', 'tab-panel', { 'tab-panel--hidden': mainTab !== 'outright' }]"
:activated="mainTab === 'outright'"
/>
</div>
</template>
<style scoped>
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
background: var(--bg-body);
}
/* Tab panel toggle: avoid display:none to prevent browser releasing image resources */
.tab-panel {
display: block;
}
.tab-panel--hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
opacity: 0;
pointer-events: none;
}
.main-tabs {
display: flex;
gap: 8px;
padding: 0 12px 12px;
}
.main-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
position: relative;
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.main-tab.active {
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.tab-icon {
font-size: 16px;
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;
justify-content: flex-start;
gap: 8px;
padding: 0 12px 12px;
}
.phase-filter {
padding: 0;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.right-filters {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.time-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
height: 24px;
box-sizing: border-box;
flex-shrink: 0;
}
.time-tab {
padding: 0 8px;
height: 100%;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.time-tab.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
font-weight: 600;
}
.right-filters .divider {
width: 1px;
height: 12px;
background: var(--border);
}
.status-toggle-btn {
padding: 0 8px;
height: 24px;
box-sizing: border-box;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.status-toggle-btn.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
border-color: var(--primary);
font-weight: 600;
}
/* League Dropdown Selector */
.league-dropdown {
position: relative;
display: inline-block;
width: 100%;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
font-size: 11px;
font-weight: 500;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
height: 24px;
box-sizing: border-box;
width: 100%;
max-width: 100%;
}
.dropdown-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-trigger .arrow-icon {
font-size: 8px;
color: var(--text-muted);
transition: transform 0.2s ease;
display: inline-block;
transform: scale(0.8);
}
.dropdown-trigger .arrow-icon.open {
transform: scale(0.8) rotate(180deg);
}
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 98;
background: transparent;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 99;
min-width: 170px;
max-width: 240px;
max-height: 200px;
overflow-y: auto;
background: #0f1217;
border: 1px solid #2a3240;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.65);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: #e5e7eb;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #1f2937;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover,
.dropdown-item:active {
background: #1b2330;
}
.dropdown-item.active {
color: #f4d06f;
background: #242f42;
font-weight: 700;
}
.league-list {
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 {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.placeholder {
padding: 80px 20px;
}
.outright-tab {
min-height: 0;
}
</style>