YD-WEB/src/views/CompetitionResultsPage.vue

147 lines
18 KiB
Vue
Raw Normal View History

<template>
<div class="competition-page">
<header class="competition-hero">
<div class="container competition-hero-inner">
<RouterLink class="competition-back" to="/sports/competition">← 竞赛演艺</RouterLink>
<div class="competition-kicker">YUEDONG SPORTS / EVENT DATA</div>
<h1>{{ title }}</h1>
<p>{{ english ? 'Event information and official results' : '赛事信息与官方成绩查询' }}</p>
<div class="competition-hero-actions">
<span :class="['competition-status', { live: state.published }]">{{ state.published ? (english ? 'PUBLISHED' : '已发布') : (english ? 'NO EVENT PUBLISHED' : '暂无已发布赛事') }}</span>
<div class="competition-language" aria-label="语言切换"><button :aria-pressed="!english" @click="setLanguage('zh')">中文</button><button :aria-pressed="english" @click="setLanguage('en')">EN</button></div>
</div>
</div>
</header>
<div class="container competition-body">
<p v-if="mode === 'static'" class="competition-message">{{ english ? 'Read-only snapshot. Live updates are unavailable.' : '当前为静态只读数据,实时更新暂不可用。' }}</p>
<div v-if="error" class="competition-message error" role="alert">{{ error }} <button @click="refresh">{{ english ? 'Retry' : '重试' }}</button></div>
<div v-if="state.notice?.enabled && noticeText" class="competition-notice" role="status"><strong>NOTICE</strong><div ref="noticeViewport" class="competition-notice-viewport"><div class="competition-notice-track" :class="{ scrolling: noticeOverflow }" :style="{ '--notice-duration': `${noticeDuration}s` }"><p><span ref="noticeContent">{{ noticeText }}</span></p><p v-if="noticeOverflow" aria-hidden="true">{{ noticeText }}</p></div></div></div>
<nav class="competition-tabs" :aria-label="english ? 'Event sections' : '赛事栏目'">
<button v-for="item in tabs" :key="item.id" :class="{ active: tab === item.id }" :aria-current="tab === item.id ? 'page' : undefined" @click="tab = item.id">{{ english ? item.en : item.zh }}</button>
</nav>
<section v-if="tab === 'event'" class="competition-panel">
<div class="competition-section-head"><small>01 / EVENT</small><h2>{{ english ? 'Event information' : '赛事信息' }}</h2></div>
<div v-if="state.published" class="competition-meta-grid">
<div><small>DATE</small><strong>{{ localized(state.competition, 'date') || '—' }}</strong></div>
<div><small>VENUE</small><strong>{{ localized(state.competition, 'venue') || '—' }}</strong></div>
<div><small>EVENT</small><strong>{{ title }}</strong></div>
</div>
<div v-if="state.published" class="competition-live-grid">
<article><small><i class="competition-pulse"></i>{{ english ? 'NOW COMPETING' : '正在进行' }}</small><h3>{{ scheduleItem(state.schedule?.currentId)?.name || '—' }}</h3><p>{{ scheduleItem(state.schedule?.currentId)?.detail || '' }}</p></article>
<article><small>{{ english ? 'CHECK-IN' : '正在检录' }}</small><h3>{{ scheduleItem(state.schedule?.callingId)?.name || '—' }}</h3><p>{{ scheduleItem(state.schedule?.callingId)?.detail || '' }}</p></article>
<article><small>{{ english ? 'UP NEXT' : '即将开始' }}</small><div v-for="item in upcoming" :key="item.id" class="competition-upcoming"><b>{{ item.time }}</b><span>{{ item.name }}</span></div><p v-if="!upcoming.length">—</p></article>
</div>
<div v-if="schedule.length" class="competition-schedule"><h3>{{ english ? 'Schedule' : '赛事日程' }}</h3><div v-for="item in schedule" :key="item.id" :class="{ current: item.id === state.schedule?.currentId }"><time>{{ item.time || '—' }}</time><strong>{{ item.name }}</strong><span>{{ item.detail }}</span></div></div>
<p v-if="!state.published || !schedule.length" class="competition-empty">{{ english ? 'Schedule information will appear after publication.' : '赛程信息发布后将在这里显示。' }}</p>
</section>
<section v-else-if="tab === 'results'" class="competition-panel">
<div class="competition-section-head"><small>02 / RESULTS</small><h2>{{ english ? 'Official results' : '赛事成绩' }}</h2></div>
<div class="competition-subtabs"><button v-for="kind in resultKinds" :key="kind.id" :class="{ active: resultKind === kind.id }" @click="resultKind = kind.id">{{ english ? kind.en : kind.zh }}</button></div>
<template v-if="resultKind === 'overall'">
<div v-if="overallBuckets.length" class="competition-filter"><label :for="'overall-group'">{{ english ? 'Group' : '组别' }}</label><select id="overall-group" v-model="bucketKey"><option v-for="bucket in overallBuckets" :key="bucket.key" :value="bucket.key">{{ bucket.group }} · {{ gender(bucket.gender) }}</option></select></div>
<div v-if="selectedBucket" class="competition-result-grid"><article v-for="event in overallEvents" :key="event.key" class="competition-result-card"><header><small>OFFICIAL RANKING</small><h3>{{ english ? event.en : event.zh }}</h3></header><div v-for="entry in rankedEntries(event.key).slice(0, 8)" :key="entry.playerId" class="competition-rank-row"><b>{{ entry[event.rank] ?? '—' }}</b><span>{{ playerName(entry.playerId) }}</span><strong>{{ entry[event.score] ?? '—' }}</strong></div><button @click="openRanking(event)">{{ english ? 'Full ranking' : '查看完整榜单' }} →</button></article></div>
<p v-else class="competition-empty">{{ english ? 'No overall results published.' : '暂无已发布的全能赛成绩。' }}</p>
</template>
<template v-else-if="resultKind === 'challenge'"><div v-if="state.results?.challenge?.length" class="competition-result-grid"><article v-for="challenge in state.results.challenge" :key="challenge.key" class="competition-result-card"><header><small>CHALLENGE</small><h3>{{ localized(challenge, 'name') }}</h3></header><div v-for="entry in ordered(challenge.rankings).slice(0, 8)" :key="entry.playerId" class="competition-rank-row"><b>{{ entry.rank ?? '—' }}</b><span>{{ playerName(entry.playerId) }}</span><strong>{{ entry.score ?? '—' }} {{ localized(challenge, 'unit') }}</strong></div><button @click="openChallenge(challenge)">{{ english ? 'Final / Qualifier' : '决赛 / 资格赛完整榜单' }} →</button></article></div><p v-else class="competition-empty">{{ english ? 'No challenge results published.' : '暂无已发布的挑战赛成绩。' }}</p></template>
<template v-else><div v-if="state.results?.battle?.length" class="competition-battles"><BattleBracket v-for="battle in state.results.battle" :key="battle.key" :battle="battle" :players="state.players" :english="english" /></div><p v-else class="competition-empty">{{ english ? 'No Battle draw published.' : '暂无已发布的 Battle 对阵。' }}</p></template>
</section>
<section v-else-if="tab === 'players'" class="competition-panel"><div class="competition-section-head"><small>03 / ATHLETES</small><h2>{{ english ? 'Athletes' : '选手查询' }}</h2></div><label class="competition-search"><span>{{ english ? 'Search by number or name' : '按编号、中文名或英文名搜索' }}</span><input v-model.trim="query" type="search" :placeholder="english ? 'Enter athlete number or name' : '输入选手编号或姓名'" /></label><div v-if="filteredPlayers.length" class="competition-player-grid"><button v-for="player in filteredPlayers" :key="player.id" class="competition-player-card" @click="selectedPlayer = player"><img :src="player.photo || avatar" :alt="playerName(player.id)" @error="fallbackImage"/><span><small>{{ player.id }} · {{ player.group }} {{ gender(player.gender) }}</small><strong>{{ playerName(player.id) }}</strong></span><b>→</b></button></div><p v-else class="competition-empty">{{ english ? 'No athletes found.' : '未找到符合条件的选手。' }}</p></section>
<section v-else class="competition-panel"><div class="competition-section-head"><small>04 / RECORDS</small><h2>{{ english ? 'Event records' : '赛会纪录' }}</h2></div><div v-if="state.records?.length" class="competition-record-grid"><article v-for="record in state.records" :key="record.id"><img :src="record.photo || avatar" :alt="localized(record, 'name')" @error="fallbackImage"/><div><small>{{ localized(record, 'event') }}</small><strong>{{ record.score }}</strong><h3>{{ localized(record, 'name') }}</h3><p>{{ record.year }}</p></div></article></div><p v-else class="competition-empty">{{ english ? 'No event records published.' : '暂无已发布的赛会纪录。' }}</p></section>
</div>
<div v-if="rankingModal || selectedPlayer" class="competition-modal-wrap" @click.self="closeModal"><div class="competition-modal" role="dialog" aria-modal="true" :aria-label="english ? 'Details' : '详情'"><button class="competition-modal-close" :aria-label="english ? 'Close' : '关闭'" @click="closeModal">×</button><template v-if="rankingModal"><small>OFFICIAL RESULTS</small><h2>{{ rankingModal.title }}</h2><div v-if="rankingModal.challenge" class="competition-subtabs"><button :class="{ active: rankingList === 'rankings' }" @click="rankingList = 'rankings'">{{ english ? 'Final' : '决赛' }}</button><button :class="{ active: rankingList === 'qualifier' }" @click="rankingList = 'qualifier'">{{ english ? 'Qualifier' : '资格赛' }}</button></div><div v-for="entry in modalEntries" :key="entry.playerId" class="competition-rank-row"><b>{{ entry.rank ?? entry[rankingModal.rank] ?? '—' }}</b><span>{{ playerName(entry.playerId) }}</span><strong>{{ entry.score ?? entry[rankingModal.score] ?? '—' }}</strong></div><p v-if="!modalEntries.length" class="competition-empty">{{ english ? 'No results.' : '暂无成绩。' }}</p></template><template v-else-if="selectedPlayer"><img class="competition-profile-photo" :src="selectedPlayer.photo || avatar" :alt="playerName(selectedPlayer.id)" @error="fallbackImage"/><small>{{ selectedPlayer.id }} · {{ selectedPlayer.group }} {{ gender(selectedPlayer.gender) }}</small><h2>{{ playerName(selectedPlayer.id) }}</h2><p>{{ selectedPlayer.note || '' }}</p><div v-for="item in playerResults" :key="item.label" class="competition-rank-row"><span>{{ item.label }}</span><strong>{{ item.score }} · #{{ item.rank ?? '—' }}</strong></div></template></div></div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { loadCompetitionState, subscribeCompetitionUpdates } from '../data/competition-api.js';
import BattleBracket from '../components/BattleBracket.vue';
const avatar = '/competition/default-avatar.svg';
const emptyState = { published: false, competition: {}, notice: {}, schedule: { items: [] }, results: { overall: [], challenge: [], battle: [] }, players: [], records: [] };
const state = ref(emptyState);
const tab = ref('event');
const resultKind = ref('overall');
const bucketKey = ref('');
const query = ref('');
const mode = ref('service');
const error = ref('');
const language = ref(localStorage.getItem('competition-language') === 'en' ? 'en' : 'zh');
const english = computed(() => language.value === 'en');
const rankingModal = ref(null);
const rankingList = ref('rankings');
const selectedPlayer = ref(null);
const tabs = [{ id: 'event', zh: '赛事信息', en: 'Event' }, { id: 'results', zh: '成绩查询', en: 'Results' }, { id: 'players', zh: '选手查询', en: 'Athletes' }, { id: 'records', zh: '赛会纪录', en: 'Records' }];
const resultKinds = [{ id: 'overall', zh: '全能赛', en: 'Overall' }, { id: 'challenge', zh: '挑战赛', en: 'Challenge' }, { id: 'battle', zh: 'Battle', en: 'Battle' }];
const overallEvents = [{ key: 'srss', zh: '30 秒单摇', en: '30s Speed', rank: 'srssRank', score: 'srss' }, { key: 'srse', zh: '3 分钟单摇', en: '3min Speed', rank: 'srseRank', score: 'srse' }, { key: 'srif', zh: '个人花样', en: 'Freestyle', rank: 'srifRank', score: 'srif' }, { key: 'overall', zh: '全能总分', en: 'Overall Points', rank: 'overallRank', score: 'overallPoints' }];
const localized = (item, field) => item?.[`${field}${english.value ? 'En' : 'Zh'}`] || item?.[`${field}${english.value ? 'Zh' : 'En'}`] || '';
const title = computed(() => localized(state.value.competition, 'name') || (english.value ? 'Event results' : '赛事成绩'));
const noticeText = computed(() => localized(state.value.notice, 'text'));
const noticeViewport = ref(null);
const noticeContent = ref(null);
const noticeOverflow = ref(false);
const noticeDuration = ref(16);
const updateNotice = () => {
if (!noticeViewport.value || !noticeContent.value) return;
const textWidth = noticeContent.value.offsetWidth;
noticeOverflow.value = textWidth > noticeViewport.value.clientWidth + 1;
noticeDuration.value = Math.max(10, Math.round((textWidth + 64) / 45));
};
let noticeObserver;
watch(noticeViewport, (element, previous) => {
if (previous) noticeObserver?.unobserve(previous);
if (element) { noticeObserver?.observe(element); nextTick(updateNotice); }
});
watch(noticeText, () => nextTick(updateNotice));
const playersById = computed(() => new Map((state.value.players || []).map((item) => [String(item.id), item])));
const playerName = (id) => { const player = playersById.value.get(String(id)); return player ? localized(player, 'name') : (id || (english.value ? 'TBD' : '待定')); };
const gender = (value) => !value ? '' : ['FEMALE', 'F', '女'].includes(String(value).toUpperCase()) ? (english.value ? 'Women' : '女子') : (english.value ? 'Men' : '男子');
const schedule = computed(() => (state.value.schedule?.items || []).map((item) => ({ ...item, name: localized(item, 'name'), detail: [item.group, gender(item.gender), localized(item, 'venue')].filter(Boolean).join(' · ') })));
const scheduleItem = (id) => schedule.value.find((item) => item.id === id);
const upcoming = computed(() => {
const explicit = state.value.schedule?.upcomingIds;
const candidates = Array.isArray(explicit) && explicit.length ? explicit.map(scheduleItem).filter(Boolean) : schedule.value.filter((item) => item.id !== state.value.schedule?.currentId && item.id !== state.value.schedule?.callingId && (!state.value.schedule?.currentId || item.time >= (scheduleItem(state.value.schedule.currentId)?.time || '')));
return candidates.slice(0, state.value.schedule?.upcomingCount ?? 3);
});
const overallBuckets = computed(() => state.value.results?.overall || []);
const selectedBucket = computed(() => overallBuckets.value.find((bucket) => bucket.key === bucketKey.value) || overallBuckets.value[0]);
watch(overallBuckets, (items) => { if (!items.some((item) => item.key === bucketKey.value)) bucketKey.value = items[0]?.key || ''; }, { immediate: true });
const ordered = (entries = [], rank = 'rank') => [...(entries || [])].sort((a, b) => Number(a[rank] ?? Infinity) - Number(b[rank] ?? Infinity));
const rankedEntries = (key) => { const event = overallEvents.find((item) => item.key === key); return ordered(selectedBucket.value?.entries, event.rank); };
const filteredPlayers = computed(() => { const term = query.value.toLowerCase(); return (state.value.players || []).filter((player) => `${player.id} ${player.nameZh} ${player.nameEn}`.toLowerCase().includes(term)).slice(0, 100); });
const modalEntries = computed(() => rankingModal.value?.challenge ? ordered(rankingModal.value.challenge[rankingList.value]) : rankedEntries(rankingModal.value?.key));
const playerResults = computed(() => { const id = selectedPlayer.value?.id; if (!id) return []; const rows = []; for (const bucket of overallBuckets.value) { const entry = bucket.entries?.find((item) => item.playerId === id); if (entry) for (const event of overallEvents) if (entry[event.score] != null) rows.push({ label: english.value ? event.en : event.zh, score: entry[event.score], rank: entry[event.rank] }); } for (const challenge of state.value.results?.challenge || []) { const final = challenge.rankings?.find((item) => item.playerId === id); const entry = final || challenge.qualifier?.find((item) => item.playerId === id); if (entry) rows.push({ label: `${final ? '' : (english.value ? 'Qualifier · ' : '资格赛 · ')}${localized(challenge, 'name')}`, score: entry.score, rank: entry.rank }); } for (const battle of state.value.results?.battle || []) for (const round of battle.rounds || []) for (const match of round.matches || []) if (resolvePlayer(match, 'a', battle) === id || resolvePlayer(match, 'b', battle) === id) rows.push({ label: `${localized(battle, 'name')} · ${localized(round, 'name')}`, score: match.winner === id ? (english.value ? 'Win' : '获胜') : (english.value ? 'Played' : '出场'), rank: '—' }); return rows; });
const openRanking = (event) => { rankingModal.value = { ...event, title: english.value ? event.en : event.zh }; };
const openChallenge = (challenge) => { rankingList.value = 'rankings'; rankingModal.value = { challenge, title: localized(challenge, 'name') }; };
const closeModal = () => { rankingModal.value = null; selectedPlayer.value = null; };
const setLanguage = (value) => { language.value = value; localStorage.setItem('competition-language', value); };
const fallbackImage = (event) => { if (!event.target.src.endsWith(avatar)) event.target.src = avatar; };
const resolvePlayer = (match, side, battle) => { const direct = match[side === 'a' ? 'aPlayer' : 'bPlayer']; if (direct) return direct; const from = match[side === 'a' ? 'aFrom' : 'bFrom']; return battle.rounds?.flatMap((round) => round.matches || []).find((item) => item.id === from)?.winner || ''; };
let unsubscribe = () => {};
let refreshing = false;
const refresh = async () => {
if (refreshing) return;
refreshing = true;
try {
const result = await loadCompetitionState();
state.value = result.state;
mode.value = result.mode;
error.value = '';
} catch (cause) {
error.value = english.value ? `Could not load event data: ${cause.message}` : `赛事数据加载失败:${cause.message}`;
} finally {
refreshing = false;
}
};
const onKeydown = (event) => { if (event.key === 'Escape') closeModal(); };
onMounted(() => { noticeObserver = new ResizeObserver(updateNotice); if (noticeViewport.value) noticeObserver.observe(noticeViewport.value); nextTick(updateNotice); unsubscribe = subscribeCompetitionUpdates(refresh); refresh(); window.addEventListener('keydown', onKeydown); });
onUnmounted(() => { noticeObserver?.disconnect(); unsubscribe(); window.removeEventListener('keydown', onKeydown); });
</script>