diff --git a/package-lock.json b/package-lock.json index 7654c49..cb8a402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 17dcd58..5e4c86f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@microsoft/signalr": "^10.0.0", "vue": "^3.5.13", "xlsx": "^0.18.5" }, diff --git a/public/static/manifest.json b/public/static/manifest.json index 14ae719..e92f475 100644 --- a/public/static/manifest.json +++ b/public/static/manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-05T08:50:41.686Z", + "generatedAt": "2026-06-10T02:58:33.251Z", "events": [ { "id": "赛事一", diff --git a/public/static/赛事一/秩序册/秩序册1场2人_模板 .xlsx b/public/static/赛事一/秩序册/秩序册1场2人_模板 .xlsx index 3e12ec3..a3a7485 100644 Binary files a/public/static/赛事一/秩序册/秩序册1场2人_模板 .xlsx and b/public/static/赛事一/秩序册/秩序册1场2人_模板 .xlsx differ diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..4eca9cf --- /dev/null +++ b/src/utils/api.js @@ -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', + }) +} diff --git a/src/utils/rosterParserEnhanced.js b/src/utils/rosterParserEnhanced.js new file mode 100644 index 0000000..8c49e94 --- /dev/null +++ b/src/utils/rosterParserEnhanced.js @@ -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) +} diff --git a/src/views/EventConfig.vue b/src/views/EventConfig.vue index ec9e90d..2b5bfef 100644 --- a/src/views/EventConfig.vue +++ b/src/views/EventConfig.vue @@ -1,7 +1,15 @@ @@ -399,19 +464,25 @@ onUnmounted(() => { {{ roundLabel }} {{ currentRoundName }} 本场 {{ participantCount }} 人 + 剩余 {{ remainingSecondsLabel }} - WS {{ wsStatus }}{{ wsError ? `:${wsError}` : '' }} + 实时 {{ serverStatus }}{{ serverError ? `:${serverError}` : '' }} + + + {{ sessionMeta.isTesting ? '测试中' : '待开始' }}
+ + + +