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.
This commit is contained in:
ltx 2026-10-20 13:38:23 +08:00
parent 31134f5627
commit 84b7b0f893
28 changed files with 972 additions and 72 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
deploy/competition-api/*.sh text eol=lf

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ node_modules/
dist/
.vite/
*.log
server/data/
outputs/competition-api-deploy-*

56
COMPETITION-DEPLOY.md Normal file
View File

@ -0,0 +1,56 @@
# 官网赛事成绩接入
官网入口为 `/#/sports/competition` 的第 4 项「赛事成绩」,独立页面为 `/#/sports/results`。`YD_Event.Web` 的「官网赛事信息」页面负责维护与发布。原需求文档的系统 B(现场评分控制台)不在此接入链路中。
## 运行接口
接口使用 Node.js 18+,没有额外依赖。生产环境必须设置以下环境变量后运行 `npm run serve:competition-api`:
| 变量 | 用途 |
|---|---|
| `COMPETITION_DATA_PATH` | 持久化 JSON 文件路径;默认 `server/data/competition-state.json`。生产环境应放在持久化磁盘并备份 |
| `COMPETITION_PORT` | 监听端口,默认 `8787` |
| `COMPETITION_HOST` | 监听地址,默认 `127.0.0.1`;分机部署时设为赛事服务的私网 IP,并用防火墙限制 `8787` 端口只允许后台服务器访问 |
| `COMPETITION_ADMIN_ALLOWED_IPS` | 允许直接访问内部管理接口的客户端 IP,逗号分隔;分机部署时填写 `YD_Event.Admin` 服务器的私网 IP。服务默认还允许本机环回地址 |
| `COMPETITION_ALLOWED_ORIGINS` | 仅在浏览器直接访问赛事服务时需要;当前正式链路由 YD_Event.Admin 转发,官网浏览器不直接连接赛事服务 |
官网通过 `https://jitsi-web.yuedongsports.com/api/competition/state` 读取已发布数据。这个匿名只读接口由 YD_Event.Admin 提供,并通过私网读取赛事服务的 `/api/competition/state`;未发布时赛事服务只返回空态。管理页仍通过需要登录的 `/api/website-competition` 读取草稿及保存,不需要管理密钥。官网每 10 秒重新读取一次公开数据。
启动赛事服务时设置 `COMPETITION_HOST=<赛事服务私网 IP>` 和 `COMPETITION_ADMIN_ALLOWED_IPS=<后台服务器私网 IP>`;在 YD_Event.Admin 设置 `CompetitionApi__AdminUrl=http://<赛事服务私网 IP>:8787/api/competition/admin`。防火墙只允许后台服务器访问赛事服务的 `8787` 端口。先从后台服务器执行 `curl -i http://<赛事服务私网 IP>:8787/api/competition/ping`,确认服务返回 `200`。`jitsi-web.yuedongsports.com` 沿用现有 `/api/` 到 YD_Event.Admin 的代理,无需将公开请求直接代理到赛事服务。
若运维已应用旧版赛事 Nginx 片段,应改用 [`deploy/nginx/jitsi-competition-api.conf`](deploy/nginx/jitsi-competition-api.conf):只屏蔽公网 `/api/competition/admin`,让公开 `/api/competition/state` 走现有后台代理。修改后执行 `nginx -t` 再重载 Nginx。
部署后从官网域名验证公开接口及 CORS 响应头:
```bash
curl -i -H 'Origin: https://www.yuedongsports.com' https://jitsi-web.yuedongsports.com/api/competition/state
curl -i https://jitsi-web.yuedongsports.com/api/competition/admin
```
第一条请求应返回 `200` 和允许 `https://www.yuedongsports.com` 的 `Access-Control-Allow-Origin`;第二条公网管理路径应返回 `404`。若公开接口仍返回 `404`,先确认 YD_Event.Admin 已部署匿名只读控制器,且旧版 Nginx 赛事代理已移除。若返回 `502`,检查内网赛事服务、`CompetitionApi__AdminUrl` 和防火墙。内部管理接口只接受 `COMPETITION_ADMIN_ALLOWED_IPS` 中的来源地址;公网不可直连赛事服务的 `8787` 端口。
2026-10-20 排查发现:此前线上 `/api/competition/state` 返回后台生成的 `502`,原因是 `test_eventapi` 容器未配置 `CompetitionApi__AdminUrl`,同机没有监听 `8787` 的赛事数据服务。部署共享网络的赛事容器后,公开接口已恢复 `200`。当前后台响应由 Furion 包装为 `statusCode/data`,官网读取代码会解包其中的赛事 JSON;官网更新构建后才会使用这项解析修复。
若赛事服务与 `test_eventapi` 部署在同一台 Docker 主机,可按 [`deploy/competition-api/README.md`](deploy/competition-api/README.md) 启动独立的 Node 容器并共享后台容器的网络命名空间。两个容器因此共用回环地址,后台现有默认 `127.0.0.1:8787` 可用,且 `8787` 不需要对公网开放。后台容器被 Jenkins 重建时必须同步重建该赛事服务容器。
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `jitsi-web.yuedongsports.com/api/competition/state` | YD_Event.Admin 的匿名只读接口;只返回已发布数据 |
| GET/PUT | `jitsi-web.yuedongsports.com/api/website-competition` | 后台登录后读取草稿、保存或发布 |
| GET/PUT | `<赛事服务私网 IP>:8787/api/competition/admin` | 仅供 YD_Event.Admin 内部调用;按来源 IP 放行 |
官网生产构建默认从 `https://jitsi-web.yuedongsports.com/api/competition/state` 读取,每 10 秒刷新一次,不再请求 `https://www.yuedongsports.com/api/competition/state`。如需覆盖,在构建官网时设置 `VITE_COMPETITION_API_URL` 为完整接口基地址。开发时官网 Vite 会把 `/api/competition` 代理到 `http://localhost:8787`,可用 `COMPETITION_API_TARGET` 覆盖。
YD_Event.Web 的「官网赛事信息」路由隐藏于菜单,未登录访问会跳转登录页;管理接口要求已有后台登录态。正式环境访问基地址为 `https://jitsi-web.yuedongsports.com/`。分机部署时将 `CompetitionApi__AdminUrl` 设为 `http://<赛事服务私网 IP>:8787/api/competition/admin`。后台管理和公开只读接口均由 YD_Event.Admin 通过私网转发到赛事服务。
升级两端服务后,旧的 `COMPETITION_ADMIN_TOKEN`、`CompetitionApi__AdminToken` 和 `CompetitionApi__AdminTokenFile` 可从部署配置中移除;它们不再被本功能读取。上线前由运维确认后台私网 IP 白名单、防火墙入站规则及公网管理路径屏蔽均已生效,再验证无密钥的后台保存流程。
## 上传格式
管理页提供包含填写说明和示例行的 Excel 模板。一次上传 `.xlsx` 文件会将赛程、全能成绩、挑战成绩、Battle、选手和纪录载入草稿,点击「保存官网赛事信息」才写入接口。页面也可下载当前数据的 Excel 文件。
Excel 工作表依次为「填写说明」「赛程」「全能成绩」「挑战成绩」「Battle场次」「选手」「纪录」。六张数据表的表头必须保留;删除示例行后可填写正式数据。全能成绩的名次使用官方成绩册数据,前台不重算名次。挑战成绩的「榜单」区分决赛和资格赛。
缺失的图片回退到默认头像。照片字段应使用浏览器可访问的 HTTPS 地址或官网同源路径;上传图片文件本身由现有图片存储服务负责。仓库内的 `public/competition-state.json` 是静态只读空态,不会存放管理后台的未发布数据。
运行接口验证:`node server/competition-api.smoke.mjs`。

View File

@ -0,0 +1,17 @@
FROM node:22-alpine
WORKDIR /app
COPY server/competition-api.mjs /app/server/competition-api.mjs
COPY public/competition-state.json /app/public/competition-state.json
RUN mkdir -p /app/data && touch /app/data/.keep && chown -R node:node /app
USER node
ENV NODE_ENV=production \
COMPETITION_DATA_PATH=/app/data/competition-state.json
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('node:http').get('http://127.0.0.1:8787/api/competition/ping', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
CMD ["node", "server/competition-api.mjs"]

View File

@ -0,0 +1,36 @@
# 在现有后台服务器启动赛事数据服务
适用于 `test_eventapi` 已在 Docker 中运行,而同机尚无赛事数据服务的部署。服务容器共享后台容器的网络命名空间,只监听 `127.0.0.1:8787`。后台现有的默认上游地址因此可直接使用;不需要发布 8787 端口,也不需要改动后台的登录验证。
## 部署
将本目录的 `Dockerfile`、`start-on-event-host.sh`,以及仓库中的 `server/competition-api.mjs`、`public/competition-state.json` 按以下结构上传到服务器的同一个目录:
```text
Dockerfile
start-on-event-host.sh
server/competition-api.mjs
public/competition-state.json
```
在该目录执行:
```sh
sh start-on-event-host.sh
docker exec test_eventapi curl -i --max-time 5 http://127.0.0.1:8787/api/competition/ping
curl -i https://jitsi-web.yuedongsports.com/api/competition/state
```
如果使用交付压缩包,可先通过 MobaXterm 的 SFTP 面板上传 `competition-api-deploy-2026-10-20.tar.gz` 到服务器,然后执行:
```sh
tar -xzf competition-api-deploy-2026-10-20.tar.gz
cd competition-api-deploy-2026-10-20
sh start-on-event-host.sh
```
脚本执行后再运行上方的两条接口验证命令。
脚本会构建单独的 Node 镜像,创建持久化 Docker 卷 `yd-competition-data`,并启动容器 `yd-competition-api`。它不会替换或重启现有后台容器。如果同名服务容器已经存在,脚本会停止并提示人工检查,不会覆盖已有数据。管理页仍需后台登录才能访问;公网不得发布 `/api/competition/admin`。
若后台容器名称不同,在运行脚本前设置 `BACKEND_CONTAINER`。若后续由 Jenkins **重建**后台容器,赛事服务需要随新的后台容器重新建立网络共享;将两者纳入同一部署流程。升级赛事服务前备份 `yd-competition-data` 卷中的 `competition-state.json`。

View File

@ -0,0 +1,52 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$script_dir"
backend_container=${BACKEND_CONTAINER:-test_eventapi}
service_container=${SERVICE_CONTAINER:-yd-competition-api}
image_name=${IMAGE_NAME:-yd-competition-api:local}
volume_name=${DATA_VOLUME:-yd-competition-data}
if [ "$(docker inspect --format '{{.State.Running}}' "$backend_container")" != true ]; then
echo "Backend container is not running: $backend_container" >&2
exit 1
fi
if docker container inspect "$service_container" >/dev/null 2>&1; then
echo "Service container already exists: $service_container" >&2
exit 1
fi
docker build -f Dockerfile -t "$image_name" .
docker volume create "$volume_name" >/dev/null
docker run -d \
--name "$service_container" \
--restart unless-stopped \
--network "container:$backend_container" \
--mount "type=volume,source=$volume_name,target=/app/data" \
"$image_name"
ready=false
attempt=0
while [ "$attempt" -lt 20 ]; do
if docker exec "$backend_container" sh -c \
'curl -fsS --max-time 5 http://127.0.0.1:8787/api/competition/ping' >/dev/null 2>&1; then
ready=true
break
fi
attempt=$((attempt + 1))
sleep 1
done
if [ "$ready" != true ]; then
echo "Competition API did not become ready on 127.0.0.1:8787" >&2
docker logs --tail 30 "$service_container" >&2
exit 1
fi
docker exec "$backend_container" sh -c \
'curl -fsS --max-time 5 http://127.0.0.1:8787/api/competition/ping'
echo
echo "Competition API is running beside $backend_container"

View File

@ -0,0 +1,6 @@
# Include inside the HTTPS server block for jitsi-web.yuedongsports.com.
# Public GET /api/competition/state is served by YD_Event.Admin through
# the existing /api/ proxy. The internal write path must stay private.
location = /api/competition/admin {
return 404;
}

View File

@ -8,7 +8,8 @@
"build": "vite build",
"rewrite:oss": "node scripts/rewrite-oss-paths.mjs",
"deploy:oss": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-oss.ps1",
"preview": "vite preview"
"preview": "vite preview",
"serve:competition-api": "node server/competition-api.mjs"
},
"dependencies": {
"vue": "^3.5.18",

View File

@ -0,0 +1,16 @@
{
"published": false,
"competition": {
"nameZh": "",
"nameEn": "",
"dateZh": "",
"dateEn": "",
"venueZh": "",
"venueEn": ""
},
"notice": { "enabled": false, "textZh": "", "textEn": "" },
"schedule": { "currentId": "", "callingId": "", "upcomingCount": 3, "items": [] },
"results": { "overall": [], "challenge": [], "battle": [] },
"players": [],
"records": []
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 500" role="img" aria-label="暂无照片"><rect width="400" height="500" fill="#242824"/><circle cx="200" cy="180" r="74" fill="#4b5149"/><path d="M63 449c10-98 57-153 137-153s127 55 137 153" fill="#4b5149"/><path d="M32 32h336v436H32z" fill="none" stroke="#8f9b87" stroke-width="2"/><text x="200" y="477" fill="#d6dfd0" font-family="sans-serif" font-size="18" text-anchor="middle">NO PHOTO</text></svg>

After

Width:  |  Height:  |  Size: 461 B

View File

@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { buildBattleLayout } from '../src/data/battle-layout.js';
const battle = {
rounds: [
{ matches: [
{ id: 'R1A', aPlayer: 'A', bPlayer: 'B', winner: 'A' },
{ id: 'R1B', aPlayer: 'C', bPlayer: 'D', winner: 'C' },
{ id: 'R1C', aPlayer: 'E', bPlayer: 'F', winner: 'E' },
{ id: 'R1D', aPlayer: 'G', bPlayer: 'H', winner: 'G' },
] },
{ matches: [
{ id: 'R2A', aPlayer: 'A', bPlayer: 'E', winner: 'A' },
{ id: 'R2B', aPlayer: 'C', bPlayer: 'G', winner: 'G' },
] },
{ matches: [{ id: 'FINAL', aPlayer: 'A', bPlayer: 'G', winner: 'A' }] },
],
};
const layout = buildBattleLayout(battle);
assert.equal(layout.lastRoundIndex, 2);
assert.deepEqual(layout.sources.get('FINAL'), { aFrom: 'R2A', bFrom: 'R2B' });
assert.deepEqual(layout.sources.get('R2A'), { aFrom: 'R1A', bFrom: 'R1C' });
assert.deepEqual([...layout.sides.left].sort(), ['R1A', 'R1C', 'R2A']);
assert.deepEqual([...layout.sides.right].sort(), ['R1B', 'R1D', 'R2B']);
const explicit = buildBattleLayout({ rounds: [
{ matches: [{ id: 'A', winner: '' }, { id: 'B', winner: '' }] },
{ matches: [{ id: 'F', aFrom: 'A', bFrom: 'B' }] },
] });
assert.deepEqual(explicit.sources.get('F'), { aFrom: 'A', bFrom: 'B' });
assert.equal(buildBattleLayout({ rounds: [{ matches: [{ id: 'ONLY' }] }] }).lastRoundIndex, 0);
console.log('Battle 轮次和晋级来源解析通过');

View File

@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { unwrapCompetitionState } from '../src/data/competition-response.js';
const state = {
published: true,
competition: {},
notice: {},
schedule: { items: [] },
results: { overall: [], challenge: [], battle: [] },
players: [],
records: [],
};
assert.deepEqual(unwrapCompetitionState(state), state);
assert.deepEqual(unwrapCompetitionState({ statusCode: 200, data: JSON.stringify(state) }), state);
assert.throws(() => unwrapCompetitionState({ statusCode: 502, errors: '上游连接失败' }), /上游连接失败/);
assert.throws(() => unwrapCompetitionState({ statusCode: 200, data: '{}' }), /结构不正确/);
console.log('Competition response parsing passed');

View File

@ -24,7 +24,8 @@ collectFiles(distDir);
// Vite has already rewritten static template/CSS URLs. Only rewrite root asset
// paths that still begin immediately after a JavaScript quote or template tick.
const rootAssetPattern = /(["'`])\/(site|materials|sports-hall)\//g;
const rootAssetPattern = /(["'`])\/(site|materials|sports-hall|competition)\//g;
const rootCompetitionDataPattern = /(["'`])\/competition-state\.json/g;
let changedFiles = 0;
for (const file of files.filter((item) => extname(item) === '.js')) {
@ -32,7 +33,7 @@ for (const file of files.filter((item) => extname(item) === '.js')) {
const rewritten = source.replace(
rootAssetPattern,
(_match, quote, folder) => `${quote}${base}${folder}/`,
);
).replace(rootCompetitionDataPattern, (_match, quote) => `${quote}${base}competition-state.json`);
if (rewritten !== source) {
writeFileSync(file, rewritten, 'utf8');
@ -49,10 +50,11 @@ for (const file of files) {
if (content.includes(duplicatePrefix) || /https:\/\/[^\s"'`]+https:\/\//.test(content)) {
duplicated.push(file);
}
if (extname(file) === '.js' && rootAssetPattern.test(content)) {
if (extname(file) === '.js' && (rootAssetPattern.test(content) || rootCompetitionDataPattern.test(content))) {
unresolved.push(file);
}
rootAssetPattern.lastIndex = 0;
rootCompetitionDataPattern.lastIndex = 0;
}
if (duplicated.length) {

144
server/competition-api.mjs Normal file
View File

@ -0,0 +1,144 @@
import { createServer } from 'node:http';
import { readFile, mkdir, rename, writeFile } from 'node:fs/promises';
import { isIP } from 'node:net';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const dataPath = resolve(process.env.COMPETITION_DATA_PATH || resolve(root, 'server/data/competition-state.json'));
const seedPath = resolve(root, 'public/competition-state.json');
const allowedOrigins = new Set((process.env.COMPETITION_ALLOWED_ORIGINS || '').split(',').map((item) => item.trim()).filter(Boolean));
const adminAllowedIps = new Set(['127.0.0.1', '::1', ...(process.env.COMPETITION_ADMIN_ALLOWED_IPS || '').split(',').map((item) => item.trim()).filter(Boolean)]);
const port = Number(process.env.COMPETITION_PORT || 8787);
const host = process.env.COMPETITION_HOST || '127.0.0.1';
const maxBody = 8 * 1024 * 1024;
const clients = new Set();
let writeQueue = Promise.resolve();
if ([...adminAllowedIps].some((address) => !isIP(address))) {
console.error('COMPETITION_ADMIN_ALLOWED_IPS 只能包含逗号分隔的 IP 地址');
process.exit(1);
}
function cors(request, response) {
const origin = request.headers.origin;
if (!origin || !allowedOrigins.has(origin)) return;
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Vary', 'Origin');
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
response.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
}
function send(response, status, value) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(JSON.stringify(value));
}
function adminIpAllowed(request) {
const address = request.socket.remoteAddress?.replace(/^::ffff:/, '');
return adminAllowedIps.has(address);
}
async function state() {
try { return JSON.parse(await readFile(dataPath, 'utf8')); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
return JSON.parse(await readFile(seedPath, 'utf8'));
}
}
function validText(value, limit = 300) {
return typeof value === 'string' && value.length <= limit;
}
function validate(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return '数据必须是 JSON 对象';
if (typeof input.published !== 'boolean') return 'published 必须是布尔值';
const c = input.competition;
if (!c || ['nameZh', 'nameEn', 'dateZh', 'dateEn', 'venueZh', 'venueEn'].some((key) => !validText(c[key]))) return '赛事名称、日期、场地字段无效';
if (input.published && !c.nameZh.trim()) return '发布前必须填写赛事中文名称';
const n = input.notice;
if (!n || typeof n.enabled !== 'boolean' || !validText(n.textZh, 1000) || !validText(n.textEn, 1000)) return '公告字段无效';
const s = input.schedule;
if (!s || !validText(s.currentId, 100) || !validText(s.callingId, 100) || !Number.isInteger(s.upcomingCount) || s.upcomingCount < 0 || s.upcomingCount > 20 || !Array.isArray(s.items) || s.items.length > 1000) return '赛程字段无效';
const ids = new Set();
for (const item of s.items) {
if (!item || !validText(item.id, 100) || !item.id || ids.has(item.id) || ['time', 'nameZh', 'nameEn', 'group', 'gender', 'venueZh', 'venueEn'].some((key) => !validText(item[key] ?? ''))) return '赛程含重复编号或无效字段';
ids.add(item.id);
}
if ((s.currentId && !ids.has(s.currentId)) || (s.callingId && !ids.has(s.callingId))) return '当前/检录场次必须存在于赛程中';
if (s.upcomingIds != null && (!Array.isArray(s.upcomingIds) || s.upcomingIds.some((id) => !ids.has(id)))) return '即将开始场次必须存在于赛程中';
if (!input.results || !['overall', 'challenge', 'battle'].every((key) => Array.isArray(input.results[key]) && input.results[key].length <= 1000)) return '成绩数据格式无效';
if (!Array.isArray(input.players) || input.players.length > 10000 || !Array.isArray(input.records) || input.records.length > 1000) return '选手或纪录数据格式无效';
if (input.players.some((player) => !player || !validText(player.id, 100) || !validText(player.nameZh || '') || !validText(player.nameEn || ''))) return '选手编号或姓名字段无效';
if (input.records.some((record) => !record || !validText(record.id, 100) || !validText(record.eventZh || '') || !validText(record.nameZh || ''))) return '纪录字段无效';
if (input.results.overall.some((bucket) => !bucket || !validText(bucket.key, 100) || !Array.isArray(bucket.entries) || bucket.entries.length > 10000 || bucket.entries.some((entry) => !entry || !validText(entry.playerId, 100)))) return '全能赛成绩格式无效';
if (input.results.challenge.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rankings) || !Array.isArray(event.qualifier) || [event.rankings, event.qualifier].some((list) => list.length > 10000 || list.some((entry) => !entry || !validText(entry.playerId, 100))))) return '挑战赛成绩格式无效';
if (input.results.battle.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rounds) || event.rounds.length > 20 || event.rounds.some((round) => !round || !Array.isArray(round.matches) || round.matches.length > 100 || round.matches.some((match) => !match || !validText(match.id, 100))))) return 'Battle 对阵格式无效';
return '';
}
async function readBody(request) {
let total = 0;
const chunks = [];
for await (const chunk of request) {
total += chunk.length;
if (total > maxBody) {
const error = new Error('数据超过 8 MB');
error.status = 413;
throw error;
}
chunks.push(chunk);
}
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch { const error = new Error('JSON 格式错误'); error.status = 400; throw error; }
}
function broadcast() {
for (const client of clients) client.write(`event: update\ndata: ${Date.now()}\n\n`);
}
const server = createServer(async (request, response) => {
cors(request, response);
if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return; }
const path = new URL(request.url, 'http://localhost').pathname;
try {
if (path === '/api/competition/ping' && request.method === 'GET') return send(response, 200, { ok: true, version: 1, mode: 'service' });
if (path === '/api/competition/state' && request.method === 'GET') {
const current = await state();
if (current.published) return send(response, 200, current);
const empty = JSON.parse(await readFile(seedPath, 'utf8'));
return send(response, 200, empty);
}
if (path === '/api/competition/stream' && request.method === 'GET') {
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
response.write(': connected\n\n');
clients.add(response);
request.on('close', () => clients.delete(response));
return;
}
if (path === '/api/competition/admin' && ['GET', 'PUT'].includes(request.method)) {
if (!adminIpAllowed(request)) return send(response, 403, { error: '管理接口仅允许指定内网地址访问' });
if (request.method === 'GET') return send(response, 200, await state());
const next = await readBody(request);
const problem = validate(next);
if (problem) return send(response, 400, { error: problem });
await (writeQueue = writeQueue.catch(() => {}).then(async () => {
await mkdir(dirname(dataPath), { recursive: true });
const temp = `${dataPath}.${process.pid}.tmp`;
await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await rename(temp, dataPath);
}));
broadcast();
return send(response, 200, { ok: true });
}
return send(response, 404, { error: '接口不存在' });
} catch (error) {
console.error(error);
return send(response, error.status || 500, { error: error.status ? error.message : '服务器处理失败' });
}
});
const heartbeat = setInterval(() => { for (const client of clients) client.write(': heartbeat\n\n'); }, 25000);
server.on('close', () => clearInterval(heartbeat));
server.listen(port, host, () => console.log(`Competition API listening on ${host}:${port}`));

View File

@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { get } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = await mkdtemp(join(tmpdir(), 'competition-api-'));
const port = 19000 + Math.floor(Math.random() * 30000);
const base = `http://127.0.0.1:${port}/api/competition`;
const server = spawn(process.execPath, ['server/competition-api.mjs'], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
env: { ...process.env, COMPETITION_ADMIN_ALLOWED_IPS: '', COMPETITION_DATA_PATH: join(directory, 'state.json'), COMPETITION_PORT: String(port), COMPETITION_ALLOWED_ORIGINS: 'https://www.yuedongsports.com' },
stdio: 'ignore',
});
try {
let ready = false;
for (let attempt = 0; attempt < 40; attempt++) {
try { ready = (await fetch(`${base}/ping`)).ok; if (ready) break; } catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, 'API did not start');
assert.equal((await fetch(`${base}/admin`)).status, 200);
const deniedStatus = await new Promise((resolve, reject) => {
get(`${base}/admin`, { localAddress: '127.0.0.2' }, (response) => {
response.resume();
resolve(response.statusCode);
}).on('error', reject);
});
assert.equal(deniedStatus, 403, 'an unlisted IP reached the admin endpoint');
const crossOriginState = await fetch(`${base}/state`, { headers: { Origin: 'https://www.yuedongsports.com' } });
assert.equal(crossOriginState.headers.get('access-control-allow-origin'), 'https://www.yuedongsports.com');
const streamController = new AbortController();
const stream = await fetch(`${base}/stream`, { signal: streamController.signal });
const reader = stream.body.getReader();
await reader.read();
const draft = JSON.parse(await (await fetch(`${base}/state`)).text());
draft.competition.nameZh = '测试赛事';
draft.schedule.items = [{ id: 'M1', time: '09:00', nameZh: '单摇', nameEn: 'Speed', group: 'A', gender: 'MALE', venueZh: '主场', venueEn: 'Hall A' }];
assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200);
const update = await Promise.race([reader.read(), new Promise((_, reject) => setTimeout(() => reject(new Error('SSE update timed out')), 2000))]);
assert.match(new TextDecoder().decode(update.value), /event: update/);
streamController.abort();
assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '', 'draft leaked to public');
draft.published = true;
assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200);
assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '测试赛事');
assert.equal((await (await fetch(`${base}/admin`)).json()).schedule.items.length, 1);
console.log('Competition API smoke test passed');
} finally {
server.kill();
await rm(directory, { recursive: true, force: true });
}

View File

@ -1,6 +1,6 @@
<template>
<div class="site-shell">
<SiteHeader />
<SiteHeader v-if="!isResultsPage" />
<main>
<RouterView v-slot="{ Component, route }">
<Transition name="page" mode="out-in">
@ -8,11 +8,16 @@
</Transition>
</RouterView>
</main>
<SiteFooter />
<SiteFooter v-if="!isResultsPage" />
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import SiteHeader from './components/SiteHeader.vue';
import SiteFooter from './components/SiteFooter.vue';
const route = useRoute();
const isResultsPage = computed(() => route.name === 'competition-results');
</script>

View File

@ -0,0 +1,117 @@
<template>
<article class="battle-event">
<h3>{{ label(battle, 'name') }}</h3>
<div ref="board" :class="['battle-board', portrait ? 'portrait' : 'landscape']" :style="portrait ? undefined : { gridTemplateColumns: `repeat(${landscapeColumns.length}, minmax(0, 1fr))`, minWidth: `${landscapeColumns.length * 180}px` }">
<svg v-if="!portrait" class="battle-lines" :viewBox="`0 0 ${size.width} ${size.height}`" preserveAspectRatio="none" aria-hidden="true">
<g v-for="line in lines" :key="line.key" :class="{ advanced: line.advanced }">
<path :d="line.path" />
<circle v-if="line.dot" :cx="line.dot.x" :cy="line.dot.y" r="2.5" />
</g>
</svg>
<template v-if="!portrait">
<section v-for="column in landscapeColumns" :key="column.key" :class="['battle-column', { 'is-final': column.key === 'final' }]"><h4>{{ column.label }}</h4><div class="battle-column-matches"><div v-for="match in column.matches" :key="match.id" :ref="(el) => setMatchRef(match.id, el)" class="battle-match"><div v-for="side in ['a', 'b']" :key="side" :class="{ winner: match.winner && match.winner === resolved(match, side) }"><img :src="photo(resolved(match, side))" alt="" @error="fallbackImage"/><span>{{ name(resolved(match, side), match, side) }}</span><b>{{ match[side === 'a' ? 'scoreA' : 'scoreB'] ?? '—' }}</b></div></div></div></section>
</template>
<template v-else>
<section v-for="stage in topStages" :key="stage.key" :class="['battle-stage', stage.side]"><h4>{{ stage.label }}</h4><div class="battle-stage-matches"><div v-for="match in stage.matches" :key="match.id" :ref="(el) => setMatchRef(match.id, el)" class="battle-match"><div v-for="side in ['a', 'b']" :key="side" :class="{ winner: match.winner && match.winner === resolved(match, side) }"><img :src="photo(resolved(match, side))" alt="" @error="fallbackImage"/><span>{{ name(resolved(match, side), match, side) }}</span><b>{{ match[side === 'a' ? 'scoreA' : 'scoreB'] ?? '—' }}</b></div></div></div></section>
<div class="battle-champion"><small>{{ roundName(lastRoundIndex) }}</small><div v-if="finalMatch" class="battle-match battle-final-match"><div v-for="side in ['a', 'b']" :key="side" :class="{ winner: finalMatch.winner && finalMatch.winner === resolved(finalMatch, side) }"><img :src="photo(resolved(finalMatch, side))" alt="" @error="fallbackImage"/><span>{{ name(resolved(finalMatch, side), finalMatch, side) }}</span><b>{{ finalMatch[side === 'a' ? 'scoreA' : 'scoreB'] ?? '—' }}</b></div></div><small>{{ english ? 'CHAMPION' : '冠军' }}</small><img :src="photo(finalMatch?.winner)" alt="" @error="fallbackImage"/><strong>{{ name(finalMatch?.winner) }}</strong></div>
<section v-for="stage in bottomStages" :key="stage.key" :class="['battle-stage', stage.side]"><h4>{{ stage.label }}</h4><div class="battle-stage-matches"><div v-for="match in stage.matches" :key="match.id" :ref="(el) => setMatchRef(match.id, el)" class="battle-match"><div v-for="side in ['a', 'b']" :key="side" :class="{ winner: match.winner && match.winner === resolved(match, side) }"><img :src="photo(resolved(match, side))" alt="" @error="fallbackImage"/><span>{{ name(resolved(match, side), match, side) }}</span><b>{{ match[side === 'a' ? 'scoreA' : 'scoreB'] ?? '—' }}</b></div></div></div></section>
</template>
</div>
</article>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { buildBattleLayout } from '../data/battle-layout.js';
const props = defineProps({ battle: { type: Object, required: true }, players: { type: Array, default: () => [] }, english: { type: Boolean, default: false } });
const avatar = '/competition/default-avatar.svg';
const board = ref(null);
const portrait = ref(false);
const lines = ref([]);
const size = ref({ width: 1, height: 1 });
const elements = new Map();
const playerMap = computed(() => new Map(props.players.map((player) => [String(player.id), player])));
const label = (item, field) => item?.[`${field}${props.english ? 'En' : 'Zh'}`] || item?.[`${field}${props.english ? 'Zh' : 'En'}`] || '';
const layout = computed(() => buildBattleLayout(props.battle));
const allMatches = computed(() => layout.value.allMatches);
const matchMap = computed(() => layout.value.matchMap);
const finalMatch = computed(() => layout.value.finalMatch);
const lastRoundIndex = computed(() => layout.value.lastRoundIndex);
const sourceId = (match, side) => layout.value.sources.get(match?.id)?.[`${side}From`] || '';
const resolved = (match, side) => match?.[`${side}Player`] || matchMap.value.get(sourceId(match, side))?.winner || '';
const name = (id, match, side) => {
if (id === 'BYE') return props.english ? 'Bye' : '轮空';
const player = playerMap.value.get(String(id));
if (player) return label(player, 'name');
if (!id && match?.[side === 'a' ? 'aFrom' : 'bFrom']) return props.english ? 'TBD' : '待定';
return props.english ? 'TBD' : '待定';
};
const photo = (id) => playerMap.value.get(String(id))?.photo || avatar;
const fallbackImage = (event) => { if (!event.target.src.endsWith(avatar)) event.target.src = avatar; };
const sides = computed(() => layout.value.sides);
const sideRound = (side, index) => (props.battle.rounds?.[index]?.matches || []).filter((match) => sides.value[side].has(match.id));
const roundName = (index) => label(props.battle.rounds?.[index], 'name') || (props.english ? `Round ${index + 1}` : `第 ${index + 1} 轮`);
const earlierRounds = computed(() => Array.from({ length: Math.max(0, lastRoundIndex.value) }, (_, index) => index));
const landscapeColumns = computed(() => [
...earlierRounds.value.map((index) => ({ key: `left-${index}`, label: roundName(index), matches: sideRound('left', index) })),
{ key: 'final', label: roundName(lastRoundIndex.value), matches: finalMatch.value ? [finalMatch.value] : [] },
...[...earlierRounds.value].reverse().map((index) => ({ key: `right-${index}`, label: roundName(index), matches: sideRound('right', index) })),
]);
const topStages = computed(() => earlierRounds.value.map((index) => ({ key: `top-${index}`, side: 'top', label: roundName(index), matches: sideRound('left', index) })));
const bottomStages = computed(() => [...earlierRounds.value].reverse().map((index) => ({ key: `bottom-${index}`, side: 'bottom', label: roundName(index), matches: sideRound('right', index) })));
const setMatchRef = (id, element) => { if (element) elements.set(id, element); else elements.delete(id); };
function draw() {
if (!board.value) return;
const bounds = board.value.getBoundingClientRect();
size.value = { width: Math.max(1, bounds.width), height: Math.max(1, bounds.height) };
if (portrait.value) { lines.value = []; return; }
const next = [];
for (const target of allMatches.value) {
const targetElement = elements.get(target.id);
if (!targetElement) continue;
const destination = targetElement.getBoundingClientRect();
const targetY = destination.top + destination.height / 2 - bounds.top;
const groups = { left: [], right: [] };
for (const upstreamId of [sourceId(target, 'a'), sourceId(target, 'b')]) {
const sourceElement = elements.get(upstreamId);
if (!sourceElement) continue;
const source = sourceElement.getBoundingClientRect();
const left = sides.value.left.has(upstreamId);
groups[left ? 'left' : 'right'].push({
id: upstreamId,
x: (left ? source.right : source.left) - bounds.left,
y: source.top + source.height / 2 - bounds.top,
advanced: Boolean(matchMap.value.get(upstreamId)?.winner),
});
}
for (const [side, sources] of Object.entries(groups)) {
if (!sources.length) continue;
const left = side === 'left';
const targetX = (left ? destination.left : destination.right) - bounds.left;
if (sources.length === 1) {
const source = sources[0];
const bend = Math.abs(targetX - source.x) * .48;
const direction = left ? 1 : -1;
next.push({ key: `${source.id}-${target.id}`, path: `M${source.x},${source.y}C${source.x + direction * bend},${source.y} ${targetX - direction * bend},${targetY} ${targetX},${targetY}`, dot: { x: source.x, y: source.y }, advanced: source.advanced });
continue;
}
const railX = (sources.reduce((sum, source) => sum + source.x, 0) / sources.length + targetX) / 2;
const ready = sources.every((source) => source.advanced);
for (const source of sources) next.push({ key: `${source.id}-${target.id}`, path: `M${source.x},${source.y}H${railX}`, dot: { x: source.x, y: source.y }, advanced: source.advanced });
const ys = [...sources.map((source) => source.y), targetY];
next.push({ key: `${target.id}-${side}-rail`, path: `M${railX},${Math.min(...ys)}V${Math.max(...ys)}`, advanced: ready });
next.push({ key: `${target.id}-${side}-join`, path: `M${railX},${targetY}H${targetX}`, dot: { x: railX, y: targetY }, advanced: ready });
}
}
lines.value = next;
}
const updateLayout = async () => { portrait.value = window.matchMedia('(orientation: portrait)').matches; await nextTick(); draw(); };
let observer;
onMounted(() => { updateLayout(); observer = new ResizeObserver(draw); if (board.value) observer.observe(board.value); window.addEventListener('orientationchange', updateLayout); window.addEventListener('resize', updateLayout); });
onUnmounted(() => { observer?.disconnect(); window.removeEventListener('orientationchange', updateLayout); window.removeEventListener('resize', updateLayout); });
watch(() => props.battle, () => nextTick(draw), { deep: true });
watch(() => props.english, () => nextTick(draw));
</script>

View File

@ -4,19 +4,10 @@
<RouterLink class="brand" to="/" aria-label="跃动首页" @click="closeMenu"><img :src="logoUrl" alt="跃动 YUEDONG" /></RouterLink>
<nav class="main-nav" aria-label="主导航">
<div v-for="item in nav" :key="item.label" class="nav-item">
<RouterLink :to="item.to" :class="{ 'section-active': isSectionActive(item) }" @click="closeMenu"><span>{{ item.label }}</span><i v-if="item.children || item.groups"></i></RouterLink>
<div v-if="item.children || item.groups" class="dropdown" :class="{ 'dropdown-mega': item.groups }">
<RouterLink :to="item.to" :class="{ 'section-active': isSectionActive(item) }" @click="closeMenu"><span>{{ item.label }}</span><i v-if="item.children"></i></RouterLink>
<div v-if="item.children" class="dropdown">
<span class="dropdown-label">{{ item.label }}</span>
<template v-if="item.groups">
<div class="dropdown-groups">
<section v-for="group in item.groups" :key="group.label" class="dropdown-group">
<RouterLink class="dropdown-group-title" :to="group.to" @click="closeMenu">{{ group.label }}</RouterLink>
</section>
</div>
</template>
<template v-else>
<RouterLink v-for="child in item.children" :key="child.label" :to="child.to" @click="closeMenu">{{ child.label }}</RouterLink>
</template>
<RouterLink v-for="child in item.children" :key="child.label" :to="child.to" @click="closeMenu">{{ child.label }}</RouterLink>
</div>
</div>
</nav>
@ -95,42 +86,11 @@ const nav = [
{
label: '跃动体育',
to: '/sports/competition',
groups: [
{
label: '竞赛演艺',
to: '/sports/competition',
children: [
{ label: '赛事招募', to: '/sports/competition#event-recruitment' },
{ label: '赛事新闻', to: '/sports/competition#event-news' },
{ label: '演艺活动', to: '/sports/competition#performances' }
]
},
{
label: '跃动运动馆',
to: '/sports/venues',
children: [
{ label: '运动馆介绍', to: '/sports/venues#venue-intro' },
{ label: '冠军班', to: '/sports/venues#champion-class' },
{ label: '特色训练营', to: '/sports/venues#camps' },
{ label: '运动馆地址', to: '/sports/venues#venue-address' }
]
},
{
label: '跃动装备',
to: '/sports/equipment',
children: [
{ label: '1阶 启蒙型', to: '/sports/equipment-stage-1' },
{ label: '2阶 初学型', to: '/sports/equipment-stage-2' },
{ label: '3阶 提速型', to: '/sports/equipment-stage-3' },
{ label: '4阶 考测型', to: '/sports/equipment-stage-4' },
{ label: '5阶 竞技型', to: '/sports/equipment-stage-5' },
{ label: '校园跳绳', to: '/sports/equipment-campus' },
{ label: '健身跳绳', to: '/sports/equipment-fitness' },
{ label: '跳绳鞋服', to: '/sports/equipment-apparel' },
{ label: '其它周边', to: '/sports/equipment-accessories' },
{ label: '购买渠道', to: '/sports/purchase-channels' }
]
}
children: [
{ label: '竞赛演艺', to: '/sports/competition' },
{ label: '跃动运动馆', to: '/sports/venues' },
{ label: '跃动装备', to: '/sports/equipment' },
{ label: '赛事信息', to: '/sports/results' }
]
},
{ label: '跃动科技', to: '/technology' },

View File

@ -12,6 +12,7 @@
>
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ item.label }}
</a>
<RouterLink v-if="mode === 'competition'" to="/sports/results" class="sports-results-nav"><span>04</span>赛事成绩 ↗</RouterLink>
</nav>
</aside>
@ -53,6 +54,10 @@
</div>
</div>
</section>
<section id="event-results" class="sports-grouped-section sports-results-entry">
<header class="sports-grouped-heading"><small>EVENT RESULTS</small><h2>赛事信息</h2><p>查看赛事日程、实时成绩、选手信息与赛会纪录。</p></header>
<RouterLink to="/sports/results">进入赛事信息 <span aria-hidden="true">↗</span></RouterLink>
</section>
</div>
<div v-else class="sports-grouped-content">

45
src/data/battle-layout.js Normal file
View File

@ -0,0 +1,45 @@
export function buildBattleLayout(battle) {
const rounds = battle?.rounds || [];
const allMatches = rounds.flatMap((round) => round.matches || []);
const matchMap = new Map(allMatches.map((match) => [match.id, match]));
const sources = new Map();
rounds.forEach((round, index) => {
const previousMatches = rounds[index - 1]?.matches || [];
for (const match of round.matches || []) {
const from = (side) => {
const explicit = match[`${side}From`];
if (explicit) return explicit;
const player = match[`${side}Player`];
if (!player) return '';
const candidates = previousMatches.filter((candidate) => candidate.winner && String(candidate.winner) === String(player));
return candidates.length === 1 ? candidates[0].id : '';
};
sources.set(match.id, { aFrom: from('a'), bFrom: from('b') });
}
});
const finalMatch = rounds.at(-1)?.matches?.[0];
const descendants = (rootId) => {
const found = new Set();
const visit = (id) => {
if (!id || found.has(id)) return;
found.add(id);
const link = sources.get(id);
visit(link?.aFrom);
visit(link?.bFrom);
};
visit(rootId);
return found;
};
const finalSources = sources.get(finalMatch?.id);
return {
allMatches,
matchMap,
sources,
finalMatch,
lastRoundIndex: rounds.length - 1,
sides: { left: descendants(finalSources?.aFrom), right: descendants(finalSources?.bFrom) },
};
}

View File

@ -0,0 +1,25 @@
import { unwrapCompetitionState } from './competition-response.js';
const configuredBase = (import.meta.env.VITE_COMPETITION_API_URL || '').trim().replace(/\/$/, '');
const base = configuredBase || (import.meta.env.PROD
? 'https://jitsi-web.yuedongsports.com/api/competition'
: '/api/competition');
export const competitionApiBase = base;
export async function loadCompetitionState() {
try {
const response = await fetch(`${base}/state`, { cache: 'no-store' });
if (!response.ok) throw new Error(`接口返回 ${response.status}`);
return { state: unwrapCompetitionState(await response.json()), mode: 'service' };
} catch (error) {
const fallback = await fetch(`${import.meta.env.BASE_URL}competition-state.json`, { cache: 'no-store' });
if (!fallback.ok) throw error;
return { state: unwrapCompetitionState(await fallback.json()), mode: 'static' };
}
}
export function subscribeCompetitionUpdates(onUpdate) {
const timer = window.setInterval(onUpdate, 10000);
return () => window.clearInterval(timer);
}

View File

@ -0,0 +1,19 @@
export function unwrapCompetitionState(payload) {
let state = payload;
for (let depth = 0; depth < 3; depth += 1) {
if (typeof state === 'string') state = JSON.parse(state);
if (!state || typeof state !== 'object' || !Object.hasOwn(state, 'statusCode')) break;
if (state.statusCode !== 200) {
throw new Error(state.error || state.message || state.errors || `接口返回 ${state.statusCode}`);
}
state = state.data;
}
if (!state || typeof state !== 'object' || typeof state.published !== 'boolean'
|| !state.competition || !state.notice || !Array.isArray(state.schedule?.items)
|| !Array.isArray(state.results?.overall) || !Array.isArray(state.results?.challenge)
|| !Array.isArray(state.results?.battle) || !Array.isArray(state.players)
|| !Array.isArray(state.records)) {
throw new Error('接口返回的赛事数据结构不正确');
}
return state;
}

View File

@ -207,7 +207,8 @@ pages.sports = {
cards: [
overviewCard('赛事招募', '汇集跃动旗下赛事报名、参赛通知与活动信息。', '/sports/competition#event-recruitment'),
overviewCard('赛事新闻', '关注专业赛事、城市活动与青少年运动员的最新动态。', '/sports/competition#event-news'),
overviewCard('演艺活动', '冠军团队、花样跳绳与大型活动演出服务。', '/sports/competition#performances')
overviewCard('演艺活动', '冠军团队、花样跳绳与大型活动演出服务。', '/sports/competition#performances'),
overviewCard('赛事信息', '查询赛事信息、选手成绩与赛会纪录。', '/sports/results')
]
},
'event-recruitment': {

View File

@ -4,6 +4,7 @@ import ContentPage from './views/ContentPage.vue';
import ContentDetailPage from './views/ContentDetailPage.vue';
import ProductDetailPage from './views/ProductDetailPage.vue';
import ContactPage from './views/ContactPage.vue';
import CompetitionResultsPage from './views/CompetitionResultsPage.vue';
const routes = [
{ path: '/', name: 'home', component: HomePage },
@ -13,6 +14,7 @@ const routes = [
{ path: '/sports/event-recruitment', redirect: '/sports/competition#event-recruitment' },
{ path: '/sports/event-news', redirect: '/sports/competition#event-news' },
{ path: '/sports/performances', redirect: '/sports/competition#performances' },
{ path: '/sports/results', name: 'competition-results', component: CompetitionResultsPage, meta: { page: 'sports-results' } },
{ path: '/sports/venue-intro', redirect: '/sports/venues#venue-intro' },
{ path: '/sports/champion-class', redirect: '/sports/venues#champion-class' },
{ path: '/sports/camps', redirect: '/sports/venues#camps' },

View File

@ -63,12 +63,6 @@ main { min-height: 72vh; }
.dropdown a::before { content: ""; position: absolute; left: 10px; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: var(--blue-300); transform: translateY(-50%); transition: .22s; }
.dropdown a:hover, .dropdown a.router-link-active { color: var(--blue-700); background: var(--blue-50); transform: translateX(3px); }
.dropdown a:hover::before { background: var(--blue-600); box-shadow: 0 0 0 4px rgba(0,144,255,.12); }
.dropdown-mega { width: min(620px,calc(100vw - 48px)); padding: 18px; }
.dropdown-groups { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }
.dropdown-group { min-width: 0; padding: 6px; border-radius: 11px; background: #f8fbff; }
.dropdown .dropdown-group-title { margin: 0; padding: 13px 14px; color: var(--blue-800); background: #eaf6ff; font-size: 14px; font-weight: 800; }
.dropdown .dropdown-group-title::before { display: none; }
.dropdown .dropdown-group-title:hover { transform: translateY(-2px); }
.nav-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.language-switch { height: 38px; display: inline-grid; grid-template-columns: repeat(2,34px); align-items: center; padding: 3px; border: 1px solid rgba(0,144,255,.2); border-radius: 20px; background: var(--blue-50); box-shadow: inset 0 0 0 1px rgba(255,255,255,.7); }
.language-switch span,.language-switch a { height: 30px; display: grid; place-items: center; border-radius: 16px; font-size: 11px; font-weight: 800; letter-spacing: .4px; transition: color .25s ease, background .25s ease, transform .25s ease, box-shadow .25s ease; }
@ -92,16 +86,16 @@ main { min-height: 72vh; }
.home-hero-slide { position: absolute; inset: 0; opacity: 0; background-position: center; background-size: cover; transform: translateX(20%) scale(1.055); transition: opacity 1.1s ease, transform 7s ease; }
.home-hero-slide.active { opacity: 1; transform: translateX(25%) scale(1.105); }
.home-hero-shade { position: absolute; inset: 0; z-index: -3; width: 100%; background: linear-gradient(90deg,transparent 45%,rgba(0,43,82,.04) 70%,rgba(0,20,42,.18) 100%); }
.home-hero-solid { position: absolute; inset: 0; z-index: -2; width: 80%; background: linear-gradient(90deg,#006fc9 0%,#0086ec 30%,#0090ff 45%,rgba(0,144,255,.98) 50%,rgba(0,144,255,.86) 55%,rgba(0,144,255,.56) 61%,rgba(0,144,255,.22) 68%,rgba(0,144,255,0) 76%); }
.home-hero-solid { position: absolute; inset: 0; z-index: -2; width: 82%; background: linear-gradient(90deg,#006fc9 0%,#0085e8 30%,rgba(0,144,255,.96) 48%,rgba(0,144,255,.76) 58%,rgba(0,126,224,.42) 68%,rgba(0,105,188,0) 79%); }
.home-hero-grid { display: none; }
.home-hero-ring { display: none; }
.home-hero-ring::after { content: ""; position: absolute; left: 12%; top: 12%; width: 9px; height: 9px; border-radius: 50%; background: #fff; box-shadow: 0 0 22px var(--blue-300); }
.ring-one { width: 570px; height: 570px; right: -160px; top: 120px; }.ring-two { width: 280px; height: 280px; right: 115px; top: 260px; animation-direction: reverse; animation-duration: 12s; }
.home-hero-content { min-height: 720px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
.home-eyebrow { display: flex; align-items: center; gap: 14px; color: var(--blue-300); font-size: 11px; font-weight: 800; letter-spacing: 4px; }
.home-eyebrow::before { content: ""; width: 42px; height: 2px; background: var(--blue-600); }
.home-eyebrow { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 800; letter-spacing: 4px; }
.home-eyebrow::before { content: ""; width: 42px; height: 2px; background: rgba(255,255,255,.9); }
.home-hero h1 { margin: 22px 0 24px; max-width: 650px; font-size: clamp(66px,7.3vw,108px); line-height: .96; letter-spacing: -6px; text-shadow: 0 18px 45px rgba(0,69,132,.2); }
.home-hero h1 em { color: var(--blue-600); font-style: normal; }
.home-hero h1 em { color: #fff; font-style: normal; }
.home-hero-content > p { max-width: 720px; margin: 0; color: rgba(255,255,255,.92); font-size: clamp(19px,1.45vw,23px); font-weight: 500; line-height: 1.75; text-align: center; text-wrap: balance; text-shadow: 0 5px 20px rgba(0,61,118,.16); }
.home-hero-actions { display: flex; align-items: center; gap: 16px; margin-top: 36px; }
.home-primary-button,.home-ghost-button { display: inline-flex; align-items: center; justify-content: center; min-height: 52px; padding: 0 24px; border-radius: 11px; font-size: 13px; font-weight: 700; transition: .3s; }
@ -138,7 +132,15 @@ main { min-height: 72vh; }
.home-product-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; }.home-product-grid > a { padding: 15px 15px 26px; border: 1px solid rgba(255,255,255,.12); border-radius: 19px 4px 19px 4px; background: rgba(255,255,255,.06); transition: .4s; }.home-product-grid > a:hover { transform: translateY(-9px); border-color: rgba(130,202,255,.5); background: rgba(0,144,255,.14); box-shadow: 0 25px 55px rgba(0,0,0,.2); }.home-product-image { position: relative; height: 300px; overflow: hidden; border-radius: 13px 3px 13px 3px; background: #fff; }.home-product-image img { width: 100%; height: 100%; object-fit: contain; transition: transform .55s; }.home-product-grid > a:hover img { transform: scale(1.06); }.home-product-image span { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; color: #fff; border-radius: 50%; background: var(--blue-600); font-size: 9px; }.home-product-grid small { display: block; margin-top: 19px; color: var(--blue-300); font-size: 10px; font-weight: 800; }.home-product-grid h3 { min-height: 52px; margin: 8px 0 15px; font-size: 18px; line-height: 1.45; }.home-product-grid b { color: rgba(235,247,255,.55); font-size: 11px; }
.home-innovation { display: grid; grid-template-columns: repeat(2,1fr); gap: 22px; padding-top: 115px; padding-bottom: 115px; }.innovation-card { position: relative; min-height: 420px; overflow: hidden; padding: 48px; color: #fff; border-radius: 26px 5px 26px 5px; box-shadow: var(--shadow); transition: transform .4s; }.innovation-card:hover { transform: translateY(-8px); }.technology-card { background: linear-gradient(145deg,#0076d4,#0090ff 70%); }.health-card { background: linear-gradient(145deg,#0090ff,#42b1ff); }.innovation-card::before { content: ""; position: absolute; right: -80px; bottom: -100px; width: 310px; height: 310px; border: 1px solid rgba(255,255,255,.2); border-radius: 50%; }.innovation-card > div { position: relative; z-index: 1; max-width: 480px; }.innovation-card small { color: var(--blue-100); font-size: 9px; font-weight: 800; letter-spacing: 3px; }.innovation-card h2 { margin: 12px 0 18px; font-size: 44px; }.innovation-card p { color: rgba(255,255,255,.8); line-height: 1.9; }.innovation-card b { display: block; margin-top: 35px; font-size: 12px; }.innovation-card > span { position: absolute; right: -8px; top: 50px; color: rgba(255,255,255,.1); font-size: 76px; font-weight: 900; letter-spacing: -4px; }
.home-news { padding: 110px 0 125px; background: var(--blue-50); }.home-news-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 24px; }.home-news-grid > a { overflow: hidden; border: 1px solid var(--line); border-radius: 20px 4px 20px 4px; background: #fff; box-shadow: 0 13px 34px rgba(0,72,135,.07); transition: .4s; }.home-news-grid > a:hover { transform: translateY(-8px); box-shadow: 0 25px 58px rgba(0,72,135,.15); }.home-news-image { position: relative; height: 260px; overflow: hidden; }.home-news-image img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s; }.home-news-grid > a:hover img { transform: scale(1.06); }.home-news-image span { position: absolute; left: 16px; top: 16px; padding: 8px 11px; color: #fff; border-radius: 7px; background: var(--blue-600); font-size: 9px; font-weight: 800; letter-spacing: 1px; }.home-news-grid > a > div:last-child { padding: 25px 26px 28px; }.home-news-grid small { color: var(--blue-600); font-size: 10px; font-weight: 800; }.home-news-grid h3 { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 59px; margin: 10px 0 13px; color: var(--ink); font-size: 20px; line-height: 1.48; }.home-news-grid p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 3; -webkit-box-orient: vertical; min-height: 69px; color: var(--muted); font-size: 13px; line-height: 1.75; }.home-news-grid b { color: var(--blue-700); font-size: 11px; }
.home-cta { position: relative; overflow: hidden; padding: 105px 0; color: #fff; text-align: center; background: var(--blue-600); }.home-cta::before { content: ""; position: absolute; inset: 0; opacity: .15; background-image: linear-gradient(rgba(255,255,255,.25) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.25) 1px,transparent 1px); background-size: 58px 58px; }.home-cta-orbit { position: absolute; width: 520px; height: 520px; right: -220px; top: -260px; border: 1px solid rgba(255,255,255,.25); border-radius: 50%; }.home-cta .container { position: relative; z-index: 1; }.home-cta small { font-size: 9px; font-weight: 800; letter-spacing: 4px; }.home-cta h2 { max-width: 900px; margin: 16px auto 18px; font-size: clamp(38px,5vw,62px); line-height: 1.2; letter-spacing: -3px; }.home-cta p { color: rgba(255,255,255,.78); }.home-cta a { display: inline-flex; align-items: center; gap: 28px; margin-top: 25px; padding: 15px 22px; color: var(--blue-800); border-radius: 11px; background: #fff; box-shadow: 0 16px 35px rgba(0,44,85,.2); font-size: 13px; font-weight: 800; transition: transform .3s; }.home-cta a:hover { transform: translateY(-4px); }
.home-cta { position: relative; overflow: hidden; padding: 112px 0 118px; color: var(--ink); text-align: center; border-top: 1px solid rgba(0,144,255,.1); background: linear-gradient(145deg,#f8fcff 0%,#eaf6ff 52%,#dcefff 100%); }
.home-cta::before { content: ""; position: absolute; inset: 0; background-image: radial-gradient(circle at 16% 25%,rgba(0,144,255,.12),transparent 28%),linear-gradient(rgba(0,144,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(0,144,255,.055) 1px,transparent 1px); background-size: auto,58px 58px,58px 58px; }
.home-cta-orbit { position: absolute; width: 520px; height: 520px; right: -220px; top: -260px; border: 1px solid rgba(0,144,255,.22); border-radius: 50%; box-shadow: 0 0 0 72px rgba(0,144,255,.035),0 0 0 144px rgba(0,144,255,.025); }
.home-cta .container { position: relative; z-index: 1; }
.home-cta small { color: var(--blue-600); font-size: 9px; font-weight: 800; letter-spacing: 4px; }
.home-cta h2 { max-width: 900px; margin: 16px auto 18px; color: var(--ink); font-size: clamp(38px,5vw,62px); line-height: 1.2; letter-spacing: -3px; }
.home-cta p { color: #587087; }
.home-cta a { display: inline-flex; align-items: center; gap: 28px; margin-top: 25px; padding: 15px 24px; color: #fff; border: 1px solid rgba(255,255,255,.3); border-radius: 11px; background: linear-gradient(135deg,#0077d5,#0090ff); box-shadow: 0 16px 35px rgba(0,112,210,.24); font-size: 13px; font-weight: 800; transition: transform .3s,box-shadow .3s; }
.home-cta a:hover { transform: translateY(-4px); box-shadow: 0 22px 42px rgba(0,112,210,.32); }
/* Original homepage, recolored without changing its content */
.home-original { padding-top: 82px; background: var(--blue-50); }
@ -715,6 +717,8 @@ main { min-height: 72vh; }
.footer-inner a:hover { color: #fff; transform: translateX(15px); }
.footer-inner a:hover::before { width: 9px; }
.copyright { position: relative; z-index: 1; width: min(1360px,calc(100% - 56px)); margin: 60px auto 0; padding-top: 22px; display: flex; justify-content: space-between; border-top: 1px solid rgba(216,238,255,.12); color: rgba(216,238,255,.38); font-size: 10px; letter-spacing: 1px; opacity: 0; transform: translateY(18px); transition: opacity .7s ease .34s, transform .8s cubic-bezier(.2,.8,.2,1) .34s; }
.copyright a { color: rgba(216,238,255,.55); transition: color .25s ease; }
.copyright a:hover { color: #fff; }
.site-footer.footer-visible::before { animation: footerGridDrift 10s linear infinite alternate; }
.footer-visible .footer-inner > div,.footer-visible .copyright { opacity: 1; transform: translateY(0); }
.footer-visible .footer-orbit { opacity: 1; transform: scale(1); }
@ -758,10 +762,6 @@ main { min-height: 72vh; }
.dropdown::before, .dropdown-label { display: none; }
.dropdown a { display: inline-block; margin-right: 18px; padding: 9px 0; font-size: 14px; }
.dropdown a::before { display: none; }
.dropdown-mega { width: 100%; padding-left: 18px; }
.dropdown .dropdown-group-title { display: block; margin: 0; padding: 10px 0; background: transparent; }
.dropdown-groups { display: block; }
.dropdown-group { margin: 5px 0 10px; padding: 8px 10px; }
.home-original { padding-top: 70px; }
.page-hero { height: 400px; padding-top: 70px; }
.scroll-cue { display: none; }
@ -844,7 +844,7 @@ main { min-height: 72vh; }
.footer-brand { grid-column: 1 / -1; }
.footer-inner > div:last-child { grid-column: auto; }
.copyright { width: calc(100% - 36px); display: block; line-height: 2; }
.copyright span { display: block; }
.copyright > * { display: block; }
.home-hero { min-height: 760px; }
.home-hero-shade,.home-hero-grid { display: none; }
.home-hero-shade { background: linear-gradient(90deg,rgba(0,99,183,.92),rgba(0,144,255,.7)),linear-gradient(0deg,rgba(0,111,201,.68),transparent 60%); }
@ -905,3 +905,134 @@ main { min-height: 72vh; }
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.reveal { opacity: 1; transform: none; }
}
.sports-results-entry > a { display: inline-flex; align-items: center; gap: 1rem; margin-top: 1.2rem; padding: .9rem 1.5rem; background: #dfff32; color: #192118; font-weight: 800; text-decoration: none; }
.sports-results-nav { display: flex; gap: .8rem; align-items: center; text-decoration: none; }
.competition-page { min-height: 100vh; background: #101512; color: #f3f5ec; }
.competition-page button, .competition-page select, .competition-page input { font: inherit; }
.competition-page button { cursor: pointer; }
.competition-page :focus-visible { outline: 3px solid #dfff32; outline-offset: 3px; }
.competition-hero { position: relative; overflow: hidden; min-height: 370px; background: radial-gradient(circle at 77% 15%, #536b2c 0, transparent 35%), linear-gradient(125deg, #151e17, #11140f 70%); border-bottom: 1px solid #354339; }
.competition-hero::after { content: '26'; position: absolute; right: 5%; bottom: -7rem; font: 900 clamp(15rem, 32vw, 36rem)/1 Arial, sans-serif; letter-spacing: -.12em; color: rgba(216, 255, 119, .055); pointer-events: none; }
.competition-hero-inner { position: relative; z-index: 1; padding-top: 4rem; padding-bottom: 3rem; }
.competition-back { color: #d5ecb9; text-decoration: none; font-size: .9rem; }
.competition-kicker { margin-top: 3rem; color: #dfff32; font-size: .76rem; letter-spacing: .28em; font-weight: 800; }
.competition-hero h1 { margin: .5rem 0 .2rem; font-size: clamp(3rem, 8vw, 6rem); line-height: 1.1; letter-spacing: -.06em; }
.competition-hero p { margin: 0; color: #b8c4b4; font-size: 1.1rem; }
.competition-hero-actions { display: flex; gap: 1rem; justify-content: space-between; align-items: center; margin-top: 2.3rem; }
.competition-status { border: 1px solid #657261; border-radius: 999px; padding: .45rem .8rem; color: #c4ccbf; font-size: .74rem; letter-spacing: .12em; }
.competition-status.live { color: #dcff77; border-color: #a5c337; }
.competition-language { display: flex; padding: .2rem; border: 1px solid #61765a; border-radius: 999px; }
.competition-language button { border: 0; background: transparent; color: #e7f0dd; padding: .3rem .8rem; border-radius: 999px; }
.competition-language button[aria-pressed='true'] { background: #dfff32; color: #172016; font-weight: 800; }
.competition-body { padding-top: 2rem; padding-bottom: 6rem; }
.competition-message { padding: .8rem 1rem; border: 1px solid #556551; background: #1e291e; color: #d5e2ce; }
.competition-message.error { border-color: #b46c55; color: #ffd7cd; }
.competition-message button { margin-left: .8rem; color: inherit; border: 0; background: transparent; text-decoration: underline; }
.competition-notice { display: flex; align-items: center; gap: 1.4rem; max-width: 100%; padding: 1rem 1.2rem; background: #dfff32; color: #1c2519; }
.competition-notice > strong { flex: none; font-size: .75rem; font-weight: 900; letter-spacing: .16em; }
.competition-notice-viewport { flex: 1; min-width: 0; overflow: hidden; }
.competition-notice-track { display: flex; width: max-content; }
.competition-notice-track p { flex: none; margin: 0; white-space: nowrap; }
.competition-notice-track p span { display: inline-block; }
.competition-notice-track.scrolling p { padding-right: 4rem; }
.competition-notice-track.scrolling { animation: competition-notice-scroll var(--notice-duration) linear infinite; }
@keyframes competition-notice-scroll { to { transform: translateX(-50%); } }
.competition-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #3c4a3f; margin: 2rem 0 3rem; }
.competition-tabs button, .competition-subtabs button { flex: none; border: 0; background: transparent; color: #aab8a9; padding: .9rem 1.4rem; }
.competition-tabs button.active { color: #eaffad; border-bottom: 3px solid #dfff32; font-weight: 800; }
.competition-section-head { margin-bottom: 2rem; }
.competition-section-head small { color: #dfff32; font-weight: 800; letter-spacing: .2em; }
.competition-section-head h2 { margin: .45rem 0; font-size: clamp(2rem, 5vw, 3.6rem); }
.competition-meta-grid, .competition-live-grid, .competition-result-grid, .competition-record-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.competition-meta-grid > div, .competition-live-grid > article, .competition-result-card, .competition-record-grid > article { border: 1px solid #3a493d; background: #1a221c; }
.competition-meta-grid > div { min-height: 100px; padding: 1.3rem; display: flex; flex-direction: column; gap: .6rem; }
.competition-meta-grid small, .competition-live-grid small, .competition-result-card small { color: #a9bb9f; font-size: .73rem; letter-spacing: .15em; }
.competition-meta-grid strong { font-size: 1.12rem; }
.competition-live-grid { margin-top: 1.3rem; }
.competition-live-grid article { min-height: 190px; padding: 1.4rem; }
.competition-live-grid h3 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; }
.competition-live-grid p { color: #aab8a9; }
.competition-pulse { display: inline-block; width: 8px; height: 8px; margin-right: .5rem; border-radius: 50%; background: #dfff32; box-shadow: 0 0 0 5px #dfff3229; }
.competition-upcoming { display: flex; gap: 1rem; border-top: 1px solid #354638; padding: .6rem 0; }
.competition-upcoming:first-of-type { margin-top: 1rem; }
.competition-upcoming b { color: #dfff32; font-variant-numeric: tabular-nums; }
.competition-schedule { margin-top: 3rem; }
.competition-schedule h3 { font-size: 1.6rem; }
.competition-schedule > div { display: grid; grid-template-columns: 6rem 1fr 1fr; gap: 1rem; align-items: center; border-top: 1px solid #3a493d; padding: 1rem; }
.competition-schedule > div.current { background: #263321; }
.competition-schedule time { color: #dfff32; font-weight: 800; font-variant-numeric: tabular-nums; }
.competition-schedule span { color: #aab8a9; }
.competition-subtabs { display: flex; gap: .3rem; margin-bottom: 1.6rem; }
.competition-subtabs button { border: 1px solid #53634f; padding: .55rem 1rem; }
.competition-subtabs button.active { background: #dfff32; border-color: #dfff32; color: #1c2719; font-weight: 800; }
.competition-filter { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.competition-filter select, .competition-search input { border: 1px solid #68766a; background: #19231b; color: #fff; padding: .7rem 1rem; }
.competition-result-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.competition-result-card { padding: 1.4rem; }
.competition-result-card header { border-bottom: 1px solid #3a493d; margin-bottom: .5rem; padding-bottom: .8rem; }
.competition-result-card h3 { font-size: 1.4rem; margin: .35rem 0 0; }
.competition-rank-row { display: flex; align-items: center; gap: .8rem; border-bottom: 1px solid #354139; padding: .65rem .15rem; }
.competition-rank-row > b { width: 2.2rem; color: #dfff32; font-size: 1.15rem; font-variant-numeric: tabular-nums; }
.competition-rank-row > span { flex: 1; overflow-wrap: anywhere; }
.competition-rank-row > strong { font-variant-numeric: tabular-nums; }
.competition-result-card > button { border: 0; background: transparent; color: #dfff32; padding: 1rem 0 .1rem; font-weight: 800; }
.competition-empty { padding: 3rem 1rem; color: #bcc9b8; text-align: center; border: 1px dashed #526352; }
.competition-search { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.4rem; }
.competition-search input { max-width: 430px; }
.competition-player-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.competition-player-card { display: flex; align-items: center; gap: 1rem; text-align: left; border: 1px solid #3a493d; background: #1a221c; color: #f3f5ec; padding: .7rem; }
.competition-player-card img { width: 70px; height: 84px; object-fit: cover; }
.competition-player-card span { flex: 1; min-width: 0; }
.competition-player-card small { display: block; color: #aab8a9; }
.competition-player-card strong { display: block; margin-top: .3rem; }
.competition-player-card b { color: #dfff32; }
.competition-record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.competition-record-grid article { display: flex; min-height: 200px; }
.competition-record-grid img { width: 38%; object-fit: cover; }
.competition-record-grid article > div { padding: 1.3rem; }
.competition-record-grid small { color: #aab8a9; }
.competition-record-grid strong { display: block; margin: .5rem 0; color: #dfff32; font: 800 clamp(2rem, 5vw, 4rem)/1 Arial, sans-serif; }
.competition-record-grid h3 { margin: 0; }
.battle-event { margin-bottom: 3rem; overflow-x: auto; padding-bottom: .5rem; }
.battle-event > h3 { margin: 0 0 1.2rem; font-size: clamp(1.35rem, 2vw, 1.8rem); }
.battle-board { position: relative; }
.battle-board.landscape { display: grid; gap: 1.5rem; height: clamp(510px, 43vw, 700px); }
.battle-column { display: flex; flex-direction: column; min-width: 0; }
.battle-column h4, .battle-stage h4 { position: relative; z-index: 1; margin: 0 0 .8rem; padding: .6rem .4rem; border: 1px solid #53624d; border-radius: 999px; background: #253124; color: #e8f5d1; font-size: .76rem; font-weight: 800; text-align: center; letter-spacing: .06em; }
.battle-column.is-final h4 { border-color: #dfff32; background: #34451f; color: #dfff32; }
.battle-column-matches { flex: 1; display: flex; flex-direction: column; justify-content: space-around; gap: .55rem; }
.battle-match { position: relative; z-index: 1; min-width: 0; overflow: hidden; border: 1px solid #607054; border-radius: 10px; background: #1d281e; box-shadow: 0 8px 24px #0005; }
.battle-match > div { display: flex; align-items: center; gap: .45rem; min-height: 3rem; padding: .35rem .5rem; border-bottom: 1px solid #3c4d3e; color: #d6e2d2; }
.battle-match > div:last-child { border-bottom: 0; }
.battle-match > div.winner { background: linear-gradient(90deg, #435f27, #304522); color: #f1ffd1; box-shadow: inset 3px 0 #dfff32; }
.battle-match img { flex: none; width: 30px; height: 30px; border: 1px solid #769270; border-radius: 50%; object-fit: cover; }
.battle-match span { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; font-size: clamp(.68rem, .85vw, .88rem); line-height: 1.25; overflow-wrap: anywhere; }
.battle-match b { flex: none; display: grid; place-items: center; min-width: 1.8rem; min-height: 1.8rem; padding: 0 .25rem; border-radius: 5px; background: #344134; color: #f5f8f0; font-size: .83rem; font-variant-numeric: tabular-nums; }
.battle-match .winner b { background: #dfff32; color: #243112; }
.battle-column.is-final .battle-match { border-color: #dfff32; box-shadow: 0 0 0 1px #dfff3240, 0 12px 30px #0007; }
.battle-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.battle-lines path { fill: none; stroke: #70836c; stroke-width: 1.5; stroke-linecap: round; opacity: .65; }
.battle-lines circle { fill: #70836c; opacity: .8; }
.battle-lines .advanced path { stroke: #b7d96a; stroke-width: 2; opacity: .9; }
.battle-lines .advanced circle { fill: #c5ed70; opacity: 1; }
.battle-board.portrait { display: flex; flex-direction: column; gap: 3.25rem; }
.battle-board.portrait > .battle-stage::after, .battle-board.portrait > .battle-champion::after { content: ''; position: absolute; left: 50%; bottom: -2.55rem; width: 2px; height: 2rem; transform: translateX(-50%); border-radius: 2px; background: linear-gradient(#6f8d5c, #a8c86a); opacity: .85; }
.battle-board.portrait > .battle-stage::before, .battle-board.portrait > .battle-champion::before { content: ''; position: absolute; left: 50%; bottom: -1.75rem; z-index: 1; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); background: #b7d96a; }
.battle-board.portrait > :last-child::before, .battle-board.portrait > :last-child::after { display: none; }
.battle-stage { position: relative; z-index: 1; }
.battle-stage h4 { width: fit-content; min-width: 9rem; margin: 0 auto .8rem; }
.battle-stage-matches { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); gap: .7rem; }
.battle-stage .battle-match span { font-size: .83rem; }
.battle-champion { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: .6rem; width: min(100%, 340px); margin: 0 auto; padding: 1.4rem; border: 2px solid #dfff32; border-radius: 16px; background: radial-gradient(circle at 50% 0, #4a6028, #243222 75%); box-shadow: 0 0 28px #dfff3226, 0 16px 36px #0007; text-align: center; }
.battle-champion small { color: #dfff32; font-weight: 800; letter-spacing: .16em; }
.battle-champion > img { width: 68px; height: 68px; border: 2px solid #dfff32; border-radius: 50%; object-fit: cover; }
.battle-champion strong { color: #f5ffd9; font-size: 1.3rem; }
.battle-final-match { width: 100%; text-align: left; }
.competition-modal-wrap { position: fixed; z-index: 1000; inset: 0; overflow-y: auto; background: #000b; padding: 3rem 1rem; }
.competition-modal { position: relative; width: min(100%, 740px); max-height: calc(100vh - 6rem); overflow-y: auto; margin: auto; padding: 2rem; background: #1c251d; border: 1px solid #7b9369; box-shadow: 0 20px 80px #0008; }
.competition-modal-close { position: absolute; right: 1rem; top: .7rem; border: 0; background: transparent; color: #fff; font-size: 2rem; }
.competition-modal > small { color: #dfff32; letter-spacing: .16em; }
.competition-modal h2 { margin: .5rem 2rem 1.5rem 0; }
.competition-profile-photo { width: 110px; height: 135px; object-fit: cover; display: block; margin-bottom: 1rem; }
@media (max-width: 760px) { .competition-meta-grid, .competition-live-grid, .competition-result-grid, .competition-player-grid, .competition-record-grid { grid-template-columns: 1fr; } .competition-schedule > div { grid-template-columns: 4rem 1fr; } .competition-schedule span { grid-column: 2; } .competition-hero-inner { padding-top: 2rem; } .competition-kicker { margin-top: 2rem; } }
@media (prefers-reduced-motion: reduce) { .competition-page *, .competition-page *::before, .competition-page *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } .competition-notice-viewport { overflow-x: auto; } }

View File

@ -0,0 +1,146 @@
<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>

View File

@ -3,5 +3,8 @@ import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: { host: '0.0.0.0' }
server: {
host: '0.0.0.0',
proxy: { '/api/competition': { target: process.env.COMPETITION_API_TARGET || 'http://localhost:8787', changeOrigin: true } }
}
});