ai 对接版
This commit is contained in:
parent
e473aacdff
commit
41ee6e4c5b
190
package-lock.json
generated
190
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"vue": "^3.5.13",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-06-05T08:50:41.686Z",
|
||||
"generatedAt": "2026-06-10T02:58:33.251Z",
|
||||
"events": [
|
||||
{
|
||||
"id": "赛事一",
|
||||
|
||||
Binary file not shown.
128
src/utils/api.js
Normal file
128
src/utils/api.js
Normal 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',
|
||||
})
|
||||
}
|
||||
242
src/utils/rosterParserEnhanced.js
Normal file
242
src/utils/rosterParserEnhanced.js
Normal 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)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user