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:
2026-06-10 13:00:14 +08:00
parent 6124313369
commit 03f54ca689
43 changed files with 2787 additions and 519 deletions

View 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>

View File

@@ -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>

View File

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