diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ab68740 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +deploy/competition-api/*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 4117859..6195edc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .vite/ *.log +server/data/ +outputs/competition-api-deploy-* diff --git a/COMPETITION-DEPLOY.md b/COMPETITION-DEPLOY.md new file mode 100644 index 0000000..909ac3e --- /dev/null +++ b/COMPETITION-DEPLOY.md @@ -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`。 diff --git a/deploy/competition-api/Dockerfile b/deploy/competition-api/Dockerfile new file mode 100644 index 0000000..bba8e46 --- /dev/null +++ b/deploy/competition-api/Dockerfile @@ -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"] diff --git a/deploy/competition-api/README.md b/deploy/competition-api/README.md new file mode 100644 index 0000000..84be632 --- /dev/null +++ b/deploy/competition-api/README.md @@ -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`。 diff --git a/deploy/competition-api/start-on-event-host.sh b/deploy/competition-api/start-on-event-host.sh new file mode 100644 index 0000000..f9c9f23 --- /dev/null +++ b/deploy/competition-api/start-on-event-host.sh @@ -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" diff --git a/deploy/nginx/jitsi-competition-api.conf b/deploy/nginx/jitsi-competition-api.conf new file mode 100644 index 0000000..67799c4 --- /dev/null +++ b/deploy/nginx/jitsi-competition-api.conf @@ -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; +} diff --git a/outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx b/outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx new file mode 100644 index 0000000..bef3561 Binary files /dev/null and b/outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx differ diff --git a/package.json b/package.json index 884ef14..2b71efc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/competition-state.json b/public/competition-state.json new file mode 100644 index 0000000..8d3d240 --- /dev/null +++ b/public/competition-state.json @@ -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": [] +} diff --git a/public/competition/default-avatar.svg b/public/competition/default-avatar.svg new file mode 100644 index 0000000..d7b331c --- /dev/null +++ b/public/competition/default-avatar.svg @@ -0,0 +1 @@ +NO PHOTO diff --git a/scripts/battle-layout-test.mjs b/scripts/battle-layout-test.mjs new file mode 100644 index 0000000..15dded6 --- /dev/null +++ b/scripts/battle-layout-test.mjs @@ -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 轮次和晋级来源解析通过'); diff --git a/scripts/competition-response-test.mjs b/scripts/competition-response-test.mjs new file mode 100644 index 0000000..4ad9b81 --- /dev/null +++ b/scripts/competition-response-test.mjs @@ -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'); diff --git a/scripts/rewrite-oss-paths.mjs b/scripts/rewrite-oss-paths.mjs index fd8662d..4c5b8ac 100644 --- a/scripts/rewrite-oss-paths.mjs +++ b/scripts/rewrite-oss-paths.mjs @@ -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) { diff --git a/server/competition-api.mjs b/server/competition-api.mjs new file mode 100644 index 0000000..8445181 --- /dev/null +++ b/server/competition-api.mjs @@ -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}`)); diff --git a/server/competition-api.smoke.mjs b/server/competition-api.smoke.mjs new file mode 100644 index 0000000..3fd7f78 --- /dev/null +++ b/server/competition-api.smoke.mjs @@ -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 }); +} diff --git a/src/App.vue b/src/App.vue index c789443..12f7962 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,6 @@ diff --git a/src/components/BattleBracket.vue b/src/components/BattleBracket.vue new file mode 100644 index 0000000..2b08334 --- /dev/null +++ b/src/components/BattleBracket.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/SiteHeader.vue b/src/components/SiteHeader.vue index 449479e..a8a6b15 100644 --- a/src/components/SiteHeader.vue +++ b/src/components/SiteHeader.vue @@ -4,19 +4,10 @@ 跃动 YUEDONG @@ -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' }, diff --git a/src/components/SportsGroupedSection.vue b/src/components/SportsGroupedSection.vue index 9132ca2..88771c9 100644 --- a/src/components/SportsGroupedSection.vue +++ b/src/components/SportsGroupedSection.vue @@ -12,6 +12,7 @@ > {{ String(index + 1).padStart(2, '0') }}{{ item.label }} + 04赛事成绩 ↗ @@ -53,6 +54,10 @@ +
+
EVENT RESULTS

赛事信息

查看赛事日程、实时成绩、选手信息与赛会纪录。

+ 进入赛事信息 +
diff --git a/src/data/battle-layout.js b/src/data/battle-layout.js new file mode 100644 index 0000000..5304382 --- /dev/null +++ b/src/data/battle-layout.js @@ -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) }, + }; +} diff --git a/src/data/competition-api.js b/src/data/competition-api.js new file mode 100644 index 0000000..8776fc1 --- /dev/null +++ b/src/data/competition-api.js @@ -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); +} diff --git a/src/data/competition-response.js b/src/data/competition-response.js new file mode 100644 index 0000000..5fc0ce9 --- /dev/null +++ b/src/data/competition-response.js @@ -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; +} diff --git a/src/data/page-content.js b/src/data/page-content.js index b67d470..08c773c 100644 --- a/src/data/page-content.js +++ b/src/data/page-content.js @@ -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': { diff --git a/src/router.js b/src/router.js index 6bf5224..2500633 100644 --- a/src/router.js +++ b/src/router.js @@ -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' }, diff --git a/src/style.css b/src/style.css index dcdec6d..5661e74 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } } diff --git a/src/views/CompetitionResultsPage.vue b/src/views/CompetitionResultsPage.vue new file mode 100644 index 0000000..070b7a3 --- /dev/null +++ b/src/views/CompetitionResultsPage.vue @@ -0,0 +1,146 @@ + + + diff --git a/vite.config.mjs b/vite.config.mjs index 481720d..7ae41e0 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -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 } } + } });