feat: refactor agent manager, media library, and player UX

- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs

- Add media library with uploaded_files migration, list/delete unused files API, and admin nav route

- Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package

- Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:56:28 +08:00
parent d5e7c8edb3
commit df20444be9
27 changed files with 2136 additions and 563 deletions

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
import BetGuideHelp from '../BetGuideHelp.vue';
import GoldSpinner from '../GoldSpinner.vue';
import TeamEmblem from '../TeamEmblem.vue';
@@ -282,51 +282,68 @@ function toggleCollapse(id: string) {
<div v-else-if="filteredMatches.length" class="match-list">
<div v-for="match in filteredMatches" :key="match.id" class="match-card" :class="{ collapsed: collapsed.has(match.id) }">
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
<span class="m-league">{{ match.leagueName }}</span>
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="m-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</span>
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="m-time">{{ formatKickoff(match.startTime) }}</span>
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">{{ collapsed.has(match.id) ? '+' : '' }}</span>
<div class="match-head-top">
<span class="m-league">{{ match.leagueName }}</span>
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">
{{ collapsed.has(match.id) ? '+' : '' }}
</span>
</div>
<div class="match-head-teams">
<div class="m-team home">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="m-name">{{ match.homeTeamName }}</span>
</div>
<div class="m-center">
<span class="m-time">{{ formatKickoff(match.startTime) }}</span>
<span class="m-vs">VS</span>
</div>
<div class="m-team away">
<span class="m-name">{{ match.awayTeamName }}</span>
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
</div>
</div>
</button>
<div v-show="!collapsed.has(match.id)" class="market-blocks">
<div
v-for="col in PARLAY_MARKET_TYPES"
:key="col.key"
class="market-block"
>
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
<div class="block-btns">
<template
v-if="
getMarket(match, col.key) &&
isParlayEligibleMarket(getMarket(match, col.key)!)
"
>
<button
v-for="sel in getMarket(match, col.key)!.selections"
:key="sel.id"
type="button"
class="odd-btn"
:class="{ picked: isPicked(sel.id) }"
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
<div v-for="group in PARLAY_MARKET_GROUPS" :key="group.headerKey" class="market-group">
<div class="group-header">{{ colLabel(group.headerKey) }}</div>
<div
v-for="col in group.columns"
:key="col.key"
class="market-row"
>
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
<div class="block-btns">
<template
v-if="
getMarket(match, col.key) &&
isParlayEligibleMarket(getMarket(match, col.key)!)
"
>
<span class="odd-label">{{ selLabel(sel) }}</span>
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
</button>
</template>
<span v-else class="market-empty">—</span>
<button
v-for="sel in getMarket(match, col.key)!.selections"
:key="sel.id"
type="button"
class="odd-btn"
:class="{ picked: isPicked(sel.id) }"
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
>
<span class="odd-label">{{ selLabel(sel) }}</span>
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
</button>
</template>
<span v-else class="market-empty">—</span>
</div>
</div>
</div>
</div>
@@ -452,20 +469,79 @@ function toggleCollapse(id: string) {
.match-head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 8px 10px;
background: none;
border: none;
color: inherit;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.match-head:active {
background: rgba(255, 255, 255, 0.015);
}
.match-head-top {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.match-head-teams {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
}
.m-team {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.m-team.away {
justify-content: flex-end;
}
.m-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
flex-shrink: 0;
padding: 0 4px;
}
.m-name {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-vs {
font-size: 9px;
font-weight: 700;
color: #555;
letter-spacing: 0.05em;
}
.m-time {
font-size: 10px;
color: var(--text-muted);
}
.toggle-dot {
flex-shrink: 0;
width: 20px;
@@ -492,26 +568,12 @@ function toggleCollapse(id: string) {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
flex-shrink: 0;
}
.m-teams {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-time {
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.match-head :deep(.team-emblem) {
width: 18px;
height: 18px;
@@ -520,12 +582,29 @@ function toggleCollapse(id: string) {
.market-blocks {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-direction: column;
gap: 8px;
padding: 8px 10px 10px;
border-top: 1px solid #1e1e1e;
}
.market-block {
.market-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 6px;
}
.group-header {
grid-column: 1 / -1;
font-size: 10px;
font-weight: 700;
color: #888;
letter-spacing: 0.04em;
padding: 0 2px 2px;
border-bottom: 1px solid #1e1e1e;
}
.market-row {
display: flex;
flex-direction: column;
gap: 2px;
@@ -538,6 +617,9 @@ function toggleCollapse(id: string) {
color: var(--text-muted);
letter-spacing: 0.03em;
padding: 0 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block-btns {
@@ -583,7 +665,6 @@ function toggleCollapse(id: string) {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 36px;
color: #444;
font-size: 12px;