YD-WEB/src/views/CompetitionResultsPage.vue
ltx 84b7b0f893 feat(website): add competition results and service deployment
- Add the event results page with schedule, rankings, Battle bracket, athletes, records, bilingual labels, and navigation entry.
- Add the read/write competition data service, a complete Excel example, empty public state, and validation smoke tests.
- Handle Furion statusCode/data responses from the production API and retain static fallback for the public page.
- Document Docker sidecar deployment beside test_eventapi, persistent storage, private admin access, Nginx routing, and OSS asset rewriting.

Verification:
- node server/competition-api.smoke.mjs
- node scripts/competition-response-test.mjs
- node scripts/battle-layout-test.mjs
- npm run build -- --base=https://assets.example.test/static/test/
- npm run rewrite:oss -- https://assets.example.test/static/test/
- Production /api/competition/state returned HTTP 200 after the sidecar started.

Deployment note: publish the updated YD-WEB static build before public results can consume the unwrapped API response. The event remains unpublished until an administrator saves and publishes data.
2026-10-20 13:38:23 +08:00

147 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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