feat: split admin dashboard, improve match ops, and player closed-match UX
Admin: add match/player overview sub-nav; refine settlement flow and league match management UI; improve action button enabled/disabled styles; enhance logo upload and outright odds sync. API: expose matchPhase/bettingOpen for closed matches; league publish guards; settlement preview with auto score save; outright team auto-sync. Player: watermark for closed/settled states; keep match and bet details visible; remove default login credentials. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
91
apps/admin/src/components/DashboardSubNav.vue
Normal file
91
apps/admin/src/components/DashboardSubNav.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', labelKey: 'nav.dashboard.matches' },
|
||||
{ path: '/dashboard/players', labelKey: 'nav.dashboard.players' },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/dashboard/players') {
|
||||
return route.path === '/dashboard/players';
|
||||
}
|
||||
return route.path === '/' || route.path === '/dashboard/matches';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="dashboard-subnav"
|
||||
:class="{ 'dashboard-subnav--embedded': embedded }"
|
||||
aria-label="Dashboard sections"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.path"
|
||||
:to="tab.path"
|
||||
class="dashboard-subnav__item"
|
||||
:class="{ 'dashboard-subnav__item--active': isActive(tab.path) }"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-subnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dashboard-subnav--embedded {
|
||||
margin-bottom: 0;
|
||||
padding: 0 14px 0 0;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dashboard-subnav__item {
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.dashboard-subnav__item:hover {
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.dashboard-subnav__item--active {
|
||||
color: var(--green-text);
|
||||
background: rgba(0, 200, 83, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import CountryFlagSelect from './outright/CountryFlagSelect.vue';
|
||||
import {
|
||||
countryLogoUrl,
|
||||
@@ -11,33 +12,61 @@ import {
|
||||
type CountryLogoKind,
|
||||
} from '../data/builtinCountries';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
teamCode?: string;
|
||||
/** 上传分类,默认 teams */
|
||||
uploadCategory?: string;
|
||||
/** 表格等窄布局:隐藏 URL 输入框,仅国家 + 国旗/队徽 */
|
||||
compact?: boolean;
|
||||
/** 仅上传与 URL,不显示国家选择 */
|
||||
uploadOnly?: boolean;
|
||||
/** 是否标记删除旧资源(父组件保存时执行) */
|
||||
deleteOld?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'update:deleteOld': [value: boolean];
|
||||
pick: [country: BuiltinCountry];
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const countryCode = ref('');
|
||||
const logoKind = ref<CountryLogoKind>('crest');
|
||||
const uploading = ref(false);
|
||||
const dragging = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const canPickCrest = computed(() => {
|
||||
const code = countryCode.value;
|
||||
return code ? hasCountryCrest(code) : false;
|
||||
});
|
||||
|
||||
const previewUrl = computed(() => props.modelValue.trim() || '');
|
||||
const previewUrl = computed(() => localUrl.value.trim() || '');
|
||||
|
||||
/** Local URL mirror for immediate preview updates regardless of parent sync */
|
||||
const localUrl = ref(props.modelValue);
|
||||
watch(() => props.modelValue, (v) => { localUrl.value = v; }, { immediate: true });
|
||||
|
||||
/**
|
||||
* Update URL both locally (instant preview) and emit to parent.
|
||||
* nextTick ensures Vue processes the parent update before continuing.
|
||||
*/
|
||||
async function updateUrl(url: string) {
|
||||
localUrl.value = url;
|
||||
emit('update:modelValue', url);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.teamCode] as const,
|
||||
([url, code]) => {
|
||||
() => [props.modelValue, props.teamCode, props.uploadOnly] as const,
|
||||
([url, code, uploadOnly]) => {
|
||||
if (uploadOnly) return;
|
||||
countryCode.value = resolveCountryCode(code, url || null);
|
||||
if (url) {
|
||||
logoKind.value = detectCountryLogoKind(url, countryCode.value);
|
||||
@@ -47,6 +76,7 @@ watch(
|
||||
);
|
||||
|
||||
watch(countryCode, (code, prev) => {
|
||||
if (props.uploadOnly) return;
|
||||
if (!code && prev && (logoKind.value === 'flag' || logoKind.value === 'crest')) {
|
||||
emit('update:modelValue', '');
|
||||
}
|
||||
@@ -56,7 +86,7 @@ function applyLogoForCountry(country: BuiltinCountry) {
|
||||
const kind =
|
||||
logoKind.value === 'crest' && hasCountryCrest(country) ? 'crest' : 'flag';
|
||||
logoKind.value = kind;
|
||||
emit('update:modelValue', countryLogoUrl(country, kind));
|
||||
updateUrl(countryLogoUrl(country, kind));
|
||||
}
|
||||
|
||||
function onCountryPick(country: BuiltinCountry) {
|
||||
@@ -73,18 +103,88 @@ function onLogoKindChange(kind: CountryLogoKind) {
|
||||
const country = getBuiltinCountry(countryCode.value);
|
||||
if (!country) return;
|
||||
logoKind.value = kind === 'crest' && !hasCountryCrest(country) ? 'flag' : kind;
|
||||
emit('update:modelValue', countryLogoUrl(country, logoKind.value as 'flag' | 'crest'));
|
||||
updateUrl(countryLogoUrl(country, logoKind.value as 'flag' | 'crest'));
|
||||
}
|
||||
|
||||
function onCustomUrlInput(value: string) {
|
||||
logoKind.value = detectCountryLogoKind(value, countryCode.value);
|
||||
emit('update:modelValue', value);
|
||||
updateUrl(value);
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
input.value = '';
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const category = props.uploadCategory || 'teams';
|
||||
const { data } = await api.post(`/admin/uploads?category=${category}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const url = data.data?.url as string;
|
||||
if (url) {
|
||||
logoKind.value = 'custom';
|
||||
await updateUrl(url);
|
||||
ElMessage.success(t('content.upload.success'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || t('content.upload.failed');
|
||||
ElMessage.error(String(msg));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
async function onFileDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const category = props.uploadCategory || 'teams';
|
||||
const { data } = await api.post(`/admin/uploads?category=${category}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const url = data.data?.url as string;
|
||||
if (url) {
|
||||
logoKind.value = 'custom';
|
||||
await updateUrl(url);
|
||||
ElMessage.success(t('content.upload.success'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || t('content.upload.failed');
|
||||
ElMessage.error(String(msg));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Track the original URL (from when component mounted) so user can mark old resource for deletion */
|
||||
const oldUrl = ref(props.modelValue);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logo-url-field" :class="{ compact }">
|
||||
<div class="pick-row">
|
||||
<div class="logo-url-field" :class="{ compact, 'upload-only': uploadOnly }">
|
||||
<template v-if="!uploadOnly">
|
||||
<CountryFlagSelect
|
||||
v-model="countryCode"
|
||||
hide-preview
|
||||
@@ -103,11 +203,46 @@ function onCustomUrlInput(value: string) {
|
||||
{{ t('teamLogo.kind.crest') }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="" class="logo-preview" loading="lazy" />
|
||||
</template>
|
||||
<div class="upload-row">
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
@click="triggerUpload"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onFileDrop"
|
||||
>
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="" class="drop-preview" loading="lazy" />
|
||||
<div v-else class="drop-placeholder">
|
||||
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span class="drop-text">{{ uploading ? '上传中…' : '拖拽或点击上传' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
v-if="oldUrl"
|
||||
class="delete-old-check"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="deleteOld"
|
||||
@change="emit('update:deleteOld', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>保存时删除旧图</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:accept="LOGO_ACCEPT"
|
||||
style="display: none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<el-input
|
||||
v-if="!compact"
|
||||
:model-value="modelValue"
|
||||
:model-value="localUrl"
|
||||
size="small"
|
||||
:placeholder="t('matchEditor.ph.logo_url')"
|
||||
clearable
|
||||
@@ -121,44 +256,107 @@ function onCustomUrlInput(value: string) {
|
||||
.logo-url-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.flag-part {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kind-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
border: 2px dashed #2a2a2a;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.delete-old-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #e55;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #2a2a2a;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.delete-old-check:hover {
|
||||
background: rgba(238, 85, 85, 0.1);
|
||||
}
|
||||
|
||||
.delete-old-check input[type="checkbox"] {
|
||||
accent-color: #e55;
|
||||
margin: 0;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: rgba(47, 181, 106, 0.4);
|
||||
background: rgba(47, 181, 106, 0.04);
|
||||
}
|
||||
|
||||
.drop-zone.is-dragging {
|
||||
border-color: #2fb56a;
|
||||
background: rgba(47, 181, 106, 0.1);
|
||||
}
|
||||
|
||||
.drop-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.drop-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.drop-text {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.url-part {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo-url-field.compact .flag-part {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.logo-url-field.compact .drop-zone {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,25 +53,27 @@ function isActive(path: string) {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.matches-subnav--embedded {
|
||||
margin-bottom: 0;
|
||||
padding: 0 12px 0 0;
|
||||
padding: 0 14px 0 0;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.matches-subnav__item {
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user