ai 对接版

This commit is contained in:
tanglong 2026-06-10 11:11:15 +08:00
parent e473aacdff
commit 41ee6e4c5b
8 changed files with 1086 additions and 169 deletions

190
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "yd-training-competition-web",
"version": "1.0.0",
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"vue": "^3.5.13",
"xlsx": "^0.18.5"
},
@ -479,6 +480,19 @@
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
@ -914,6 +928,18 @@
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.35.tgz",
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
@ -1015,6 +1041,24 @@
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@ -1032,6 +1076,16 @@
}
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
@ -1079,6 +1133,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -1123,6 +1197,39 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.61.0",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.61.0.tgz",
@ -1167,6 +1274,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1202,6 +1315,46 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
@ -1296,6 +1449,22 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
@ -1312,6 +1481,27 @@
"node": ">=0.8"
}
},
"node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",

View File

@ -12,6 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"vue": "^3.5.13",
"xlsx": "^0.18.5"
},

View File

@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-05T08:50:41.686Z",
"generatedAt": "2026-06-10T02:58:33.251Z",
"events": [
{
"id": "赛事一",

128
src/utils/api.js Normal file
View File

@ -0,0 +1,128 @@
const DEFAULT_API_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:9996').trim()
export function resolveApiBase(baseUrl = '') {
const value = (baseUrl || DEFAULT_API_BASE).trim()
return value.replace(/\/+$/, '')
}
export function resolveHubUrl(baseUrl = '') {
return `${resolveApiBase(baseUrl)}/hubs/scoring`
}
async function request(baseUrl, path, options = {}) {
const url = `${resolveApiBase(baseUrl)}${path}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
})
let payload = null
const text = await response.text()
if (text) {
try {
payload = JSON.parse(text)
} catch {
payload = text
}
}
if (!response.ok) {
const message =
(payload && typeof payload === 'object' && payload.message) ||
response.statusText ||
'请求失败'
throw new Error(message)
}
return payload
}
export function getCompetitionSession(baseUrl) {
return request(baseUrl, '/api/competition/session')
}
export function createCompetitionSession(baseUrl, payload) {
return request(baseUrl, '/api/competition/session', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function selectCompetitionRound(baseUrl, roundIndex) {
return request(baseUrl, `/api/competition/rounds/${roundIndex}/select`, {
method: 'POST',
})
}
export function nextCompetitionRound(baseUrl) {
return request(baseUrl, '/api/competition/rounds/next', {
method: 'POST',
})
}
export function previousCompetitionRound(baseUrl) {
return request(baseUrl, '/api/competition/rounds/previous', {
method: 'POST',
})
}
export function downloadCurrentRound(baseUrl) {
return request(baseUrl, '/api/competition/rounds/current/download', {
method: 'POST',
})
}
export function startCompetition(baseUrl) {
return request(baseUrl, '/api/competition/test/start', {
method: 'POST',
})
}
export function stopCompetition(baseUrl) {
return request(baseUrl, '/api/competition/test/stop', {
method: 'POST',
})
}
export function resetCompetitionRound(baseUrl) {
return request(baseUrl, '/api/competition/test/reset', {
method: 'POST',
})
}
export function setParticipantCount(baseUrl, participantId, count) {
return request(baseUrl, `/api/competition/participants/${participantId}/count`, {
method: 'POST',
body: JSON.stringify({ count }),
})
}
export function changeParticipantCount(baseUrl, participantId, delta) {
return request(baseUrl, `/api/competition/participants/${participantId}/delta`, {
method: 'POST',
body: JSON.stringify({ delta }),
})
}
export function getSerialPorts(baseUrl) {
return request(baseUrl, '/api/serial/ports')
}
export function getSerialStatus(baseUrl) {
return request(baseUrl, '/api/serial/status')
}
export function openSerialPort(baseUrl, portName) {
return request(baseUrl, `/api/serial/open/${encodeURIComponent(portName)}`, {
method: 'POST',
})
}
export function closeSerialPort(baseUrl) {
return request(baseUrl, '/api/serial/close', {
method: 'POST',
})
}

View File

@ -0,0 +1,242 @@
import * as XLSX from 'xlsx'
import { resolveStaticUrl } from './staticUrl.js'
const NAME_HEADERS = ['姓名', '名字', '选手', '名称', 'name', 'Name', '选手姓名']
const PROJECT_HEADERS = ['项目', '比赛项目', '参赛项目', 'project', 'Project']
const ADDRESS_HEADERS = ['通信地址', '手柄地址', '设备地址', '终端地址', 'RFID', 'rfid', 'address', 'Address']
const TOTAL_ROUNDS_RE = /场次总数|总场次|轮次总数|总轮次/
const ROUND_SIZE_RE = /每场人数|每轮人数|单场人数|每场|人数/
const ADDRESS_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
function normalizeCell(value) {
return String(value ?? '').trim()
}
function normalizeRow(row) {
return row.map(normalizeCell)
}
function rowText(row) {
return normalizeRow(row).filter(Boolean).join(' ')
}
function numbersInRow(row) {
return normalizeRow(row)
.flatMap((cell) => cell.match(/\d+/g) ?? [])
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
}
function findColumn(row, headers) {
return row.findIndex((cell) => headers.includes(normalizeCell(cell)))
}
function normalizeAddress(value) {
const raw = normalizeCell(value)
return ADDRESS_RE.test(raw) ? raw : ''
}
function findAddressInRow(row) {
return normalizeRow(row).find((cell) => ADDRESS_RE.test(cell)) || ''
}
function isValidName(value) {
const name = normalizeCell(value)
if (!name) return false
if (NAME_HEADERS.includes(name)) return false
if (/^\d+$/.test(name)) return false
if (ADDRESS_RE.test(name)) return false
return true
}
function isValidProject(value) {
const project = normalizeCell(value)
if (!project) return false
if (PROJECT_HEADERS.includes(project)) return false
return true
}
function collectParticipants(rows) {
const participants = []
let activeNameCol = -1
let activeProjectCol = -1
let activeAddressCol = -1
for (const row of rows) {
const nameHeaderCol = findColumn(row, NAME_HEADERS)
if (nameHeaderCol >= 0) {
activeNameCol = nameHeaderCol
activeProjectCol = findColumn(row, PROJECT_HEADERS)
activeAddressCol = findColumn(row, ADDRESS_HEADERS)
continue
}
if (activeNameCol < 0) continue
const name = normalizeCell(row[activeNameCol])
if (!isValidName(name)) continue
const projectName =
activeProjectCol >= 0 && isValidProject(row[activeProjectCol])
? normalizeCell(row[activeProjectCol])
: ''
const communicationAddress =
activeAddressCol >= 0
? normalizeAddress(row[activeAddressCol])
: findAddressInRow(row)
participants.push({
name,
projectName,
communicationAddress,
})
}
if (participants.length) return participants
return rows
.map((row) => ({
name: normalizeCell(row[0]),
projectName: '',
communicationAddress: findAddressInRow(row),
}))
.filter((participant) => isValidName(participant.name))
}
function summarizeRoundName(participants, index) {
const names = [...new Set(participants.map((p) => normalizeCell(p.projectName)).filter(Boolean))]
return names.length ? names.join(' / ') : `第 ${index + 1} 场`
}
function parseTxtRows(text) {
return text
.split(/\r?\n/)
.map((line) => line.replace(/#.*$/, '').trim())
.filter(Boolean)
.map((line) => {
if (line.includes('\t')) return line.split('\t')
return line.split(/[,,]/)
})
}
function parseCsvRows(text) {
const lines = text.split(/\r?\n/).filter((line) => line.trim())
if (!lines.length) return []
return lines.map((line) => {
if (line.includes('\t')) return line.split('\t')
return line.split(/[,,]/).map((item) => item.trim().replace(/^"|"$/g, ''))
})
}
function parseWorkbookRows(buffer) {
const workbook = XLSX.read(buffer, { type: 'array' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' })
}
function parseRosterRows(rows) {
const normalizedRows = rows.map(normalizeRow).filter((row) => row.some(Boolean))
if (!normalizedRows.length) return null
const eventName = normalizedRows[0].find(Boolean) || ''
const metaRows = new Set([0])
let totalRounds = 1
let roundParticipantCounts = []
normalizedRows.forEach((row, index) => {
const text = rowText(row)
const numbers = numbersInRow(row)
if (!numbers.length) return
if (TOTAL_ROUNDS_RE.test(text)) {
totalRounds = numbers[0]
metaRows.add(index)
return
}
if (ROUND_SIZE_RE.test(text)) {
roundParticipantCounts = numbers
metaRows.add(index)
}
})
if (!roundParticipantCounts.length && normalizedRows[1]) {
const numbers = numbersInRow(normalizedRows[1])
if (numbers.length) {
totalRounds = numbers[0]
metaRows.add(1)
}
}
if (!roundParticipantCounts.length && normalizedRows[2]) {
const numbers = numbersInRow(normalizedRows[2])
if (numbers.length) {
roundParticipantCounts = numbers
metaRows.add(2)
}
}
if (roundParticipantCounts.length > 1 && totalRounds <= 1) {
totalRounds = roundParticipantCounts.length
}
const nameRows = normalizedRows.filter((_, index) => !metaRows.has(index))
const participants = collectParticipants(nameRows)
if (!roundParticipantCounts.length && participants.length) {
roundParticipantCounts = [participants.length]
} else if (roundParticipantCounts.length === 1 && totalRounds > 1) {
roundParticipantCounts = Array.from({ length: totalRounds }, () => roundParticipantCounts[0])
} else if (roundParticipantCounts.length < totalRounds) {
const fallback = roundParticipantCounts.at(-1) || participants.length
roundParticipantCounts = [
...roundParticipantCounts,
...Array.from({ length: totalRounds - roundParticipantCounts.length }, () => fallback),
]
} else {
roundParticipantCounts = roundParticipantCounts.slice(0, totalRounds)
}
const roundNames = []
let start = 0
roundParticipantCounts.forEach((count, index) => {
const roundParticipants = participants.slice(start, start + count)
roundNames.push(summarizeRoundName(roundParticipants, index))
start += count
})
return {
eventName,
totalRounds: Math.max(1, totalRounds),
roundParticipantCounts,
roundNames,
participants,
names: participants.map((p) => p.name),
}
}
async function loadRosterRows(url) {
const response = await fetch(resolveStaticUrl(url))
if (!response.ok) throw new Error('无法读取秩序册文件')
const ext = url.split('.').pop()?.toLowerCase().replace(/\?.*$/, '') ?? ''
if (ext === 'txt') return parseTxtRows(await response.text())
if (ext === 'csv') return parseCsvRows(await response.text())
if (ext === 'xlsx' || ext === 'xls') return parseWorkbookRows(await response.arrayBuffer())
throw new Error('不支持的秩序册格式,请使用 txt / csv / xlsx')
}
export async function parseRosterDetails(url) {
const details = parseRosterRows(await loadRosterRows(url))
if (!details?.names.length) throw new Error('秩序册中未解析到参赛姓名')
return details
}
export async function parseRoster(url, max = 20) {
const details = await parseRosterDetails(url)
return details.names.slice(0, max)
}

View File

@ -1,7 +1,15 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { saveProjectionSession } from '../composables/useEventSession.js'
import { parseRosterDetails } from '../utils/rosterParser.js'
import {
closeSerialPort,
createCompetitionSession,
getSerialPorts,
getSerialStatus,
openSerialPort,
resolveApiBase,
} from '../utils/api.js'
import { parseRosterDetails } from '../utils/rosterParserEnhanced.js'
import { fetchStaticManifest } from '../utils/staticManifest.js'
import { cssBackgroundImage, resolveStaticUrl } from '../utils/staticUrl.js'
import { loadEventConfig, saveEventConfig } from '../utils/storage.js'
@ -17,7 +25,7 @@ const selectedBackground = ref('')
const selectedPassword = ref('')
const selectedLogo = ref('')
const selectedRoster = ref('')
const websocketUrl = ref('')
const backendBaseUrl = ref(resolveApiBase())
const logoLayout = ref({ x: 12, y: 18, scale: 1 })
const titleLayout = ref({ x: 50, y: 8, fontSize: 34 })
const participantArea = ref({ x: 4, y: 22, w: 92, h: 68 })
@ -36,6 +44,12 @@ const backgroundRatio = ref({ w: 16, h: 9 })
const eventTitle = ref('')
const configReady = ref(false)
const skipNextEventDefaults = ref(false)
const serialPorts = ref([])
const serialStatus = ref({ isOpen: false, port: '' })
const selectedSerialPort = ref('')
const serialLoading = ref(false)
const serialError = ref('')
const testDurationSeconds = ref(60)
const selectedEvent = computed(() =>
events.value.find((e) => e.id === selectedEventId.value),
@ -132,8 +146,8 @@ function applyEventDefaults(event) {
selectedPassword.value = event.passwords[0]?.path ?? ''
selectedLogo.value = event.logos[0]?.path ?? ''
selectedRoster.value = event.rosters[0]?.path ?? ''
websocketUrl.value = ''
eventTitle.value = event.name
testDurationSeconds.value = 60
logoLayout.value = { x: 12, y: 18, scale: 1 }
titleLayout.value = { x: 50, y: 8, fontSize: 34 }
participantArea.value = { x: 4, y: 22, w: 92, h: 68 }
@ -175,7 +189,10 @@ function applySavedConfig(saved, event) {
}
eventTitle.value = typeof saved.eventTitle === 'string' ? saved.eventTitle : event.name
websocketUrl.value = typeof saved.websocketUrl === 'string' ? saved.websocketUrl : ''
backendBaseUrl.value = resolveApiBase(saved.backendBaseUrl || backendBaseUrl.value)
if (typeof saved.testDurationSeconds === 'number' && saved.testDurationSeconds > 0) {
testDurationSeconds.value = Math.min(Math.max(Math.round(saved.testDurationSeconds), 1), 3600)
}
const savedTitle = saved.pageLayout?.title ?? saved.titleLayout
const savedLogo = saved.pageLayout?.logo ?? saved.logoLayout
@ -246,6 +263,12 @@ function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function normalizeDuration(value) {
const num = Number(value)
if (!Number.isFinite(num)) return 60
return Math.min(Math.max(Math.round(num), 1), 3600)
}
function updateLogoPosition(e) {
if (!previewBgRef.value) return
const rect = previewBgRef.value.getBoundingClientRect()
@ -434,6 +457,63 @@ async function loadRosterPreview() {
}
}
async function refreshSerial() {
serialLoading.value = true
serialError.value = ''
try {
const base = resolveApiBase(backendBaseUrl.value)
const [ports, status] = await Promise.all([
getSerialPorts(base),
getSerialStatus(base),
])
serialPorts.value = Array.isArray(ports) ? ports : []
serialStatus.value = {
isOpen: Boolean(status?.isOpen),
port: status?.port || '',
}
selectedSerialPort.value = serialStatus.value.port || (
serialPorts.value.includes(selectedSerialPort.value) ? selectedSerialPort.value : ''
)
} catch (e) {
serialError.value = e.message || '无法连接后端串口接口'
serialPorts.value = []
serialStatus.value = { isOpen: false, port: '' }
} finally {
serialLoading.value = false
}
}
async function handleOpenSerial() {
serialError.value = ''
if (!selectedSerialPort.value) {
serialError.value = '请选择串口'
return
}
serialLoading.value = true
try {
await openSerialPort(backendBaseUrl.value, selectedSerialPort.value)
await refreshSerial()
} catch (e) {
serialError.value = e.message || '打开串口失败'
} finally {
serialLoading.value = false
}
}
async function handleCloseSerial() {
serialError.value = ''
serialLoading.value = true
try {
await closeSerialPort(backendBaseUrl.value)
await refreshSerial()
} catch (e) {
serialError.value = e.message || '关闭串口失败'
} finally {
serialLoading.value = false
}
}
function persistConfig() {
if (!configReady.value || !selectedEvent.value) return
saveEventConfig({
@ -443,7 +523,8 @@ function persistConfig() {
selectedPassword: selectedPassword.value,
selectedLogo: selectedLogo.value,
selectedRoster: selectedRoster.value,
websocketUrl: websocketUrl.value.trim(),
backendBaseUrl: resolveApiBase(backendBaseUrl.value),
testDurationSeconds: testDurationSeconds.value,
backgroundRatio: { ...backgroundRatio.value },
pageLayout: pageLayoutConfig.value,
})
@ -475,7 +556,8 @@ watch(
selectedPassword,
selectedLogo,
selectedRoster,
websocketUrl,
backendBaseUrl,
testDurationSeconds,
backgroundRatio,
titleLayout,
logoLayout,
@ -485,6 +567,10 @@ watch(
{ deep: true },
)
watch(backendBaseUrl, () => {
refreshSerial()
})
async function buildSession() {
startError.value = ''
if (!canStart.value) {
@ -508,7 +594,8 @@ async function buildSession() {
passwordPath: selectedPassword.value,
commandAudioPath: selectedPassword.value,
rosterPath: selectedRoster.value,
websocketUrl: websocketUrl.value.trim(),
apiBaseUrl: resolveApiBase(backendBaseUrl.value),
testDurationSeconds: normalizeDuration(testDurationSeconds.value),
totalRounds: details.totalRounds,
roundParticipantCounts: details.roundParticipantCounts,
roundNames: details.roundNames,
@ -516,6 +603,7 @@ async function buildSession() {
id: i + 1,
name: participant.name,
projectName: participant.projectName,
rfid: participant.communicationAddress || '',
count: 0,
})),
}
@ -524,7 +612,24 @@ async function buildSession() {
async function handleStart() {
try {
const session = await buildSession()
if (session) emit('start', session)
if (!session) return
const state = await createCompetitionSession(session.apiBaseUrl, {
eventId: session.eventId,
eventName: session.eventName,
rosterPath: session.rosterPath,
testDurationSeconds: session.testDurationSeconds,
totalRounds: session.totalRounds,
roundParticipantCounts: session.roundParticipantCounts,
roundNames: session.roundNames,
participants: session.participants,
})
emit('start', {
...session,
totalRounds: state?.totalRounds ?? session.totalRounds,
roundParticipantCounts: state?.roundParticipantCounts ?? session.roundParticipantCounts,
roundNames: state?.roundNames ?? session.roundNames,
participants: state?.participants?.length ? state.participants : session.participants,
})
} catch (e) {
startError.value = e.message || '无法进入比赛'
}
@ -535,7 +640,26 @@ async function handleProjection() {
const session = await buildSession()
if (!session) return
saveProjectionSession(session)
const state = await createCompetitionSession(session.apiBaseUrl, {
eventId: session.eventId,
eventName: session.eventName,
rosterPath: session.rosterPath,
testDurationSeconds: session.testDurationSeconds,
totalRounds: session.totalRounds,
roundParticipantCounts: session.roundParticipantCounts,
roundNames: session.roundNames,
participants: session.participants,
})
const projectionSession = {
...session,
totalRounds: state?.totalRounds ?? session.totalRounds,
roundParticipantCounts: state?.roundParticipantCounts ?? session.roundParticipantCounts,
roundNames: state?.roundNames ?? session.roundNames,
participants: state?.participants?.length ? state.participants : session.participants,
}
saveProjectionSession(projectionSession)
const url = new URL(window.location.href)
url.searchParams.set('screen', 'counter')
window.open(url.toString(), 'rope-skipping-projection')
@ -545,6 +669,7 @@ async function handleProjection() {
}
loadManifest()
refreshSerial()
onUnmounted(() => {
stopLogoDrag()
@ -647,18 +772,71 @@ onUnmounted(() => {
</section>
<section class="section">
<label class="section-label">WebSocket 数据地址(可选)</label>
<label class="section-label">后端接口地址</label>
<input
v-model.trim="websocketUrl"
v-model.trim="backendBaseUrl"
class="input"
type="text"
placeholder="例如 ws://localhost:8080/competition"
placeholder="例如 http://localhost:9996"
/>
<p class="field-tip">
配置后进入比赛或投屏时会自动连接,用于接收比赛计数数据。
进入比赛后会通过该地址调用比赛接口并连接 SignalR 实时推送。
</p>
</section>
<section class="section">
<label class="section-label">考试时长(秒)</label>
<input
v-model.number="testDurationSeconds"
class="input"
type="number"
min="1"
max="3600"
step="1"
placeholder="例如 60"
/>
<p class="field-tip">
下载本场时会将该时长下发到设备,默认 60 秒。
</p>
</section>
<section class="section">
<label class="section-label">2.4G 串口状态</label>
<div class="serial-row">
<select v-model="selectedSerialPort" class="select">
<option value="">请选择串口</option>
<option v-for="port in serialPorts" :key="port" :value="port">
{{ port }}
</option>
</select>
<button type="button" class="serial-btn" :disabled="serialLoading" @click="refreshSerial">
刷新
</button>
</div>
<div class="serial-actions">
<button
type="button"
class="serial-btn primary"
:disabled="serialLoading || !selectedSerialPort"
@click="handleOpenSerial"
>
打开串口
</button>
<button
type="button"
class="serial-btn danger"
:disabled="serialLoading || !serialStatus.isOpen"
@click="handleCloseSerial"
>
关闭串口
</button>
</div>
<p class="field-tip">
{{ serialStatus.isOpen ? `当前已连接:${serialStatus.port}` : '当前未连接串口' }}
</p>
<p v-if="serialError" class="start-error">{{ serialError }}</p>
</section>
<p v-if="startError" class="start-error">{{ startError }}</p>
<div class="action-row">
@ -1064,6 +1242,41 @@ onUnmounted(() => {
color: #64748b;
}
.serial-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 84px;
gap: 0.5rem;
}
.serial-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.serial-btn {
padding: 0.65rem 0.75rem;
color: #0f172a;
background: #e2e8f0;
border-radius: 8px;
font-weight: 600;
}
.serial-btn.primary {
color: #fff;
background: linear-gradient(135deg, #2563eb, #0ea5e9);
}
.serial-btn.danger {
color: #fff;
background: linear-gradient(135deg, #ef4444, #f97316);
}
.serial-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary-btn {
width: 100%;
padding: 0.65rem 0.75rem;

View File

@ -1,10 +1,24 @@
<script setup>
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ParticipantCard from '../components/ParticipantCard.vue'
import {
loadMatchState,
saveMatchState,
} from '../composables/useEventSession.js'
import {
changeParticipantCount,
downloadCurrentRound,
getCompetitionSession,
nextCompetitionRound,
previousCompetitionRound,
resetCompetitionRound,
resolveApiBase,
resolveHubUrl,
setParticipantCount,
startCompetition,
stopCompetition,
} from '../utils/api.js'
import { getGridLayout } from '../utils/gridLayout.js'
import { cssBackgroundImage, resolveStaticUrl } from '../utils/staticUrl.js'
@ -26,20 +40,30 @@ const currentRoundIndex = ref(0)
const backgroundUrl = ref('')
const editMode = ref(false)
const commandAudioRef = ref(null)
const wsStatus = ref('未配置')
const wsError = ref('')
const wsRef = ref(null)
const wsReconnectTimer = ref(null)
const serverStatus = ref('未连接')
const serverError = ref('')
const connectionRef = ref(null)
const actionBusy = ref(false)
const actionError = ref('')
const sessionMeta = ref({
totalRounds: 1,
roundParticipantCounts: [],
roundNames: [],
isTesting: false,
testDurationSeconds: null,
remainingSeconds: null,
})
const apiBaseUrl = computed(() => resolveApiBase(props.session.apiBaseUrl || ''))
const roundParticipantCounts = computed(() => {
const counts = (props.session.roundParticipantCounts || [])
const counts = (sessionMeta.value.roundParticipantCounts || props.session.roundParticipantCounts || [])
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
return counts.length ? counts : [props.session.participants?.length || 0]
})
const totalRounds = computed(() =>
Math.max(Number(props.session.totalRounds) || 0, roundParticipantCounts.value.length, 1),
Math.max(Number(sessionMeta.value.totalRounds) || Number(props.session.totalRounds) || 0, roundParticipantCounts.value.length, 1),
)
const roundStartIndex = computed(() =>
roundParticipantCounts.value.reduce((starts, count, index) => {
@ -62,11 +86,18 @@ const allRoundsTotalCount = computed(() =>
)
const roundLabel = computed(() => `第 ${currentRoundIndex.value + 1} / ${totalRounds.value} 场`)
const currentRoundName = computed(
() => props.session.roundNames?.[currentRoundIndex.value] || `第 ${currentRoundIndex.value + 1} 场`,
() => sessionMeta.value.roundNames?.[currentRoundIndex.value] || props.session.roundNames?.[currentRoundIndex.value] || `第 ${currentRoundIndex.value + 1} 场`,
)
const canPrevRound = computed(() => currentRoundIndex.value > 0)
const canNextRound = computed(() => currentRoundIndex.value < totalRounds.value - 1)
const websocketUrl = computed(() => (props.session.websocketUrl || '').trim())
const remainingSecondsLabel = computed(() => {
const value = sessionMeta.value.remainingSeconds
if (!Number.isFinite(value) || value === null || value < 0) return '--'
const total = Math.max(0, Number(value))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
const commandAudioUrl = computed(() =>
resolveStaticUrl(props.session.commandAudioPath || props.session.passwordPath || ''),
)
@ -133,6 +164,14 @@ function initFromSession() {
const s = props.session
eventName.value = s.eventName
backgroundUrl.value = resolveStaticUrl(s.backgroundPath || '')
sessionMeta.value = {
totalRounds: s.totalRounds || 1,
roundParticipantCounts: [...(s.roundParticipantCounts || [])],
roundNames: [...(s.roundNames || [])],
isTesting: false,
testDurationSeconds: s.testDurationSeconds ?? null,
remainingSeconds: s.testDurationSeconds ?? null,
}
const saved = loadMatchState(s.eventId)
if (saved?.participants?.length === s.participants.length) {
@ -160,19 +199,36 @@ function globalIndex(i) {
function increment(i) {
const participant = allParticipants.value[globalIndex(i)]
if (participant) participant.count++
if (!participant) return
if (!apiBaseUrl.value) {
participant.count++
return
}
updateRemoteParticipant(participant.id, 1)
}
function decrement(i) {
const participant = allParticipants.value[globalIndex(i)]
if (participant?.count > 0) {
participant.count--
if (!participant) return
if (!apiBaseUrl.value) {
if (participant.count > 0) {
participant.count--
}
return
}
if (participant.count > 0) {
updateRemoteParticipant(participant.id, -1)
}
}
function resetOne(i) {
const participant = allParticipants.value[globalIndex(i)]
if (participant) participant.count = 0
if (!participant) return
if (!apiBaseUrl.value) {
participant.count = 0
return
}
updateRemoteCount(participant.id, 0)
}
function updateName(i, name) {
@ -180,6 +236,71 @@ function updateName(i, name) {
if (participant) participant.name = name
}
function applyCompetitionState(state) {
if (!state?.loaded) return
eventName.value = state.eventName || eventName.value
currentRoundIndex.value = Number.isInteger(state.currentRoundIndex) ? state.currentRoundIndex : 0
const currentRoundParticipants = Array.isArray(state.participants)
? state.participants.filter((participant) => participant.roundIndex === state.currentRoundIndex)
: []
const remainingCandidates = currentRoundParticipants
.map((participant) => participant.remainingSeconds)
.filter((value) => Number.isFinite(value))
sessionMeta.value = {
totalRounds: state.totalRounds || 1,
roundParticipantCounts: [...(state.roundParticipantCounts || [])],
roundNames: [...(state.roundNames || [])],
isTesting: Boolean(state.isTesting),
testDurationSeconds: state.testDurationSeconds ?? sessionMeta.value.testDurationSeconds ?? null,
remainingSeconds:
remainingCandidates.length > 0
? Math.max(0, Math.min(...remainingCandidates))
: (state.testDurationSeconds ?? sessionMeta.value.testDurationSeconds ?? null),
}
if (Array.isArray(state.participants) && state.participants.length) {
allParticipants.value = state.participants
.slice()
.sort((a, b) => (a.roundIndex - b.roundIndex) || (a.slotIndex - b.slotIndex) || (a.id - b.id))
.map((participant, index) => ({
id: participant.id ?? index + 1,
name: participant.name || `选手 ${index + 1}`,
projectName: participant.projectName || '',
count: Number(participant.count) || 0,
}))
}
}
async function loadRemoteState() {
if (!apiBaseUrl.value) return
try {
const state = await getCompetitionSession(apiBaseUrl.value)
applyCompetitionState(state)
} catch (e) {
serverError.value = e.message || '无法读取比赛状态'
}
}
async function updateRemoteParticipant(participantId, delta) {
actionError.value = ''
try {
const state = await changeParticipantCount(apiBaseUrl.value, participantId, delta)
applyCompetitionState(state)
} catch (e) {
actionError.value = e.message || '更新计数失败'
}
}
async function updateRemoteCount(participantId, count) {
actionError.value = ''
try {
const state = await setParticipantCount(apiBaseUrl.value, participantId, count)
applyCompetitionState(state)
} catch (e) {
actionError.value = e.message || '更新计数失败'
}
}
function playCommandAudio() {
if (!commandAudioRef.value) return
commandAudioRef.value.currentTime = 0
@ -195,168 +316,111 @@ function persistCounts() {
)
}
async function runServerAction(action) {
if (!apiBaseUrl.value) return null
actionBusy.value = true
actionError.value = ''
try {
const state = await action()
applyCompetitionState(state)
return state
} catch (e) {
actionError.value = e.message || '操作失败'
return null
} finally {
actionBusy.value = false
}
}
function prevRound() {
if (!canPrevRound.value) return
currentRoundIndex.value--
editMode.value = false
if (!apiBaseUrl.value) {
currentRoundIndex.value--
return
}
runServerAction(() => previousCompetitionRound(apiBaseUrl.value))
}
function nextRound() {
if (!canNextRound.value) return
currentRoundIndex.value++
editMode.value = false
if (!apiBaseUrl.value) {
currentRoundIndex.value++
return
}
runServerAction(() => nextCompetitionRound(apiBaseUrl.value))
}
function zeroBased(value) {
const number = Number(value)
if (!Number.isInteger(number) || number <= 0) return -1
return number - 1
function startMatch() {
runServerAction(() => startCompetition(apiBaseUrl.value))
}
function findParticipantIndex(message) {
const participantId = message.participantId ?? message.id
if (participantId !== undefined) {
const id = Number(participantId)
const index = allParticipants.value.findIndex((p) => Number(p.id) === id)
if (index >= 0) return index
}
const globalIndex = zeroBased(message.globalIndex)
if (globalIndex >= 0 && globalIndex < allParticipants.value.length) return globalIndex
const slotIndex = zeroBased(message.slotIndex)
if (slotIndex >= 0 && slotIndex < participantCount.value) {
return currentRoundStart.value + slotIndex
}
if (message.name) {
return allParticipants.value.findIndex((p) => {
if (p.name !== message.name) return false
return !message.projectName || p.projectName === message.projectName
})
}
return -1
function stopMatch() {
runServerAction(() => stopCompetition(apiBaseUrl.value))
}
function updateParticipantFromMessage(message) {
const index = findParticipantIndex(message)
if (index < 0) return
function resetRound() {
runServerAction(() => resetCompetitionRound(apiBaseUrl.value))
}
const participant = allParticipants.value[index]
const type = message.type || 'count'
if (type === 'increment') {
const delta = Number(message.delta ?? 1)
participant.count += Number.isFinite(delta) ? delta : 1
function downloadRoundToDevices() {
runServerAction(() => downloadCurrentRound(apiBaseUrl.value))
}
async function connectServer() {
if (!apiBaseUrl.value) {
serverStatus.value = '未配置'
return
}
if (type === 'decrement') {
const delta = Number(message.delta ?? 1)
participant.count = Math.max(0, participant.count - (Number.isFinite(delta) ? delta : 1))
return
}
await disconnectServer()
serverError.value = ''
if (type === 'reset') {
participant.count = 0
return
}
const connection = new HubConnectionBuilder()
.withUrl(resolveHubUrl(apiBaseUrl.value))
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build()
const count = Number(message.count)
if (Number.isFinite(count)) {
participant.count = Math.max(0, count)
}
}
connection.on('competition-state', (state) => {
applyCompetitionState(state)
})
function applyRemoteRound(message) {
const index = zeroBased(message.roundIndex)
if (index < 0 || index >= totalRounds.value) return
currentRoundIndex.value = index
editMode.value = false
}
connection.onreconnecting(() => {
serverStatus.value = '重连中'
})
function handleWsMessage(raw) {
let message
connection.onreconnected(() => {
serverStatus.value = '已连接'
loadRemoteState()
})
connection.onclose(() => {
serverStatus.value = '已断开'
})
connectionRef.value = connection
try {
message = JSON.parse(raw)
} catch {
wsError.value = '收到非 JSON 数据'
return
}
const payload = message.payload && typeof message.payload === 'object' ? message.payload : message
const type = payload.type || 'count'
if (type === 'round') {
applyRemoteRound(payload)
return
}
if (type === 'batch') {
if (payload.roundIndex) applyRemoteRound(payload)
;(payload.participants || payload.items || []).forEach(updateParticipantFromMessage)
return
}
updateParticipantFromMessage(payload)
}
function clearWsReconnectTimer() {
if (!wsReconnectTimer.value) return
clearTimeout(wsReconnectTimer.value)
wsReconnectTimer.value = null
}
function disconnectWebSocket() {
clearWsReconnectTimer()
if (!wsRef.value) return
wsRef.value.onopen = null
wsRef.value.onmessage = null
wsRef.value.onerror = null
wsRef.value.onclose = null
wsRef.value.close()
wsRef.value = null
}
function connectWebSocket() {
disconnectWebSocket()
wsError.value = ''
if (!websocketUrl.value) {
wsStatus.value = '未配置'
return
}
try {
wsStatus.value = '连接中'
const ws = new WebSocket(websocketUrl.value)
wsRef.value = ws
ws.onopen = () => {
wsStatus.value = '已连接'
wsError.value = ''
}
ws.onmessage = (event) => {
handleWsMessage(event.data)
}
ws.onerror = () => {
wsError.value = '连接异常'
}
ws.onclose = () => {
wsStatus.value = '已断开'
wsRef.value = null
clearWsReconnectTimer()
wsReconnectTimer.value = setTimeout(connectWebSocket, 3000)
}
serverStatus.value = '连接中'
await connection.start()
serverStatus.value = '已连接'
} catch (e) {
wsStatus.value = '连接失败'
wsError.value = e.message || '无法创建 WebSocket 连接'
serverStatus.value = '连接失败'
serverError.value = e.message || '无法连接实时服务'
}
}
async function disconnectServer() {
if (!connectionRef.value) return
try {
await connectionRef.value.stop()
} catch {
// ignore stop errors when tearing down the page
}
connectionRef.value = null
}
watch(allParticipants, persistCounts, { deep: true })
watch(eventName, persistCounts)
watch(currentRoundIndex, persistCounts)
@ -367,14 +431,15 @@ function onKeydown(e) {
}
}
onMounted(() => {
onMounted(async () => {
initFromSession()
connectWebSocket()
await loadRemoteState()
await connectServer()
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
disconnectWebSocket()
disconnectServer()
window.removeEventListener('keydown', onKeydown)
})
</script>
@ -399,19 +464,25 @@ onUnmounted(() => {
<strong>{{ roundLabel }}</strong>
<span class="round-name">{{ currentRoundName }}</span>
<span>本场 {{ participantCount }} 人</span>
<span>剩余 {{ remainingSecondsLabel }}</span>
<span
v-if="websocketUrl"
class="ws-status"
:class="{ connected: wsStatus === '已连接', error: wsError || wsStatus === '已断开' }"
:class="{ connected: serverStatus === '已连接', error: serverError || serverStatus === '已断开' }"
>
WS {{ wsStatus }}{{ wsError ? `:${wsError}` : '' }}
实时 {{ serverStatus }}{{ serverError ? `:${serverError}` : '' }}
</span>
<span
class="match-status"
:class="{ running: sessionMeta.isTesting }"
>
{{ sessionMeta.isTesting ? '测试中' : '待开始' }}
</span>
</div>
<div class="toolbar">
<button
type="button"
class="tool-btn"
:disabled="!canPrevRound"
:disabled="!canPrevRound || actionBusy"
@click="prevRound"
>
上一场
@ -419,11 +490,47 @@ onUnmounted(() => {
<button
type="button"
class="tool-btn"
:disabled="!canNextRound"
:disabled="!canNextRound || actionBusy"
@click="nextRound"
>
下一场
</button>
<button
v-if="apiBaseUrl"
type="button"
class="tool-btn"
:disabled="actionBusy"
@click="downloadRoundToDevices"
>
下载本场
</button>
<button
v-if="apiBaseUrl"
type="button"
class="tool-btn start-btn"
:disabled="actionBusy"
@click="startMatch"
>
开始
</button>
<button
v-if="apiBaseUrl"
type="button"
class="tool-btn stop-btn"
:disabled="actionBusy"
@click="stopMatch"
>
停止
</button>
<button
v-if="apiBaseUrl"
type="button"
class="tool-btn"
:disabled="actionBusy"
@click="resetRound"
>
清零
</button>
<button
type="button"
class="tool-btn"
@ -453,6 +560,8 @@ onUnmounted(() => {
/>
</header>
<p v-if="actionError" class="action-error">{{ actionError }}</p>
<main class="grid-container" :class="{ compact }" :style="gridStyle">
<ParticipantCard
v-for="(p, i) in participants"
@ -612,6 +721,18 @@ onUnmounted(() => {
background: rgba(239, 68, 68, 0.24);
}
.match-status {
padding: 0.12rem 0.45rem;
color: #dbeafe;
background: rgba(37, 99, 235, 0.26);
border-radius: 999px;
}
.match-status.running {
color: #bbf7d0;
background: rgba(34, 197, 94, 0.22);
}
.toolbar {
display: flex;
gap: 0.5rem;
@ -636,6 +757,14 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.35);
}
.tool-btn.start-btn {
background: rgba(34, 197, 94, 0.28);
}
.tool-btn.stop-btn {
background: rgba(239, 68, 68, 0.28);
}
.tool-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
@ -651,6 +780,20 @@ onUnmounted(() => {
align-content: stretch;
}
.action-error {
position: absolute;
z-index: 6;
top: 4.3rem;
left: 1.2rem;
right: 1.2rem;
margin: 0;
padding: 0.45rem 0.75rem;
color: #fff;
background: rgba(220, 38, 38, 0.72);
border-radius: 10px;
font-size: 0.8rem;
}
.page-footer {
position: absolute;
z-index: 4;