fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+61 -1
View File
@@ -5,8 +5,19 @@
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
*/
/**
* FormData / Blob(含 File)/ URLSearchParams 不是普通对象:
* 展开或 JSON 序列化都会丢掉内容,必须原样交给 fetch(报名表导入就是被展平才丢文件)。
*/
export function isRawBody(value: unknown): value is FormData | Blob | URLSearchParams {
if (typeof FormData !== 'undefined' && value instanceof FormData) return true
if (typeof Blob !== 'undefined' && value instanceof Blob) return true
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return true
return false
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isRawBody(value)
}
export function readString(source: unknown, key: string): string | null {
@@ -14,3 +25,52 @@ export function readString(source: unknown, key: string): string | null {
const value = source[key]
return typeof value === 'string' ? value : null
}
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}
/** 依次取第一个可用值:容忍服务端把数字写成字符串(settings 表存的是文本)。 */
export function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === 'string') return value
}
return null
}
export function firstNumber(def: number, ...values: unknown[]): number {
const found = firstNumberOrNull(...values)
return found === null ? def : found
}
export function firstNumberOrNull(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
}
return null
}
export function firstBool(def: boolean, ...values: unknown[]): boolean {
for (const value of values) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false
}
}
}
return def
}
export function emptyToNull(value: string | null): string | null {
return value === null || value.trim() === '' ? null : value
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 服务端两类载荷的归一化。
*
* REST 与 WebSocket 快照对同一份数据用了两套字段名(快照直接序列化 domain 类型),
* 归一化放在 shared 层,display 与 admin 各自只认归一后的契约类型。
*/
import {
asRecord,
emptyToNull,
firstBool,
firstNumber,
firstNumberOrNull,
firstString,
isRecord,
} from './internal'
import type { EventSettings, StandingRow } from './types'
/**
* 赛事配置归一化。
*
* `GET /api/settings` 是嵌套对象,而 WebSocket 快照的 `settings` 是
* settings 表的点号扁平 map(`display.rotate_seconds`、`standings.mode`…)。
* 两种形状都接受,缺省值对齐服务端 `getSettings`,避免大屏首包读不到字段。
*/
export function normalizeSettings(raw: unknown): EventSettings {
const source = asRecord(raw)
const nestedEvent = isRecord(source.event) ? source.event : null
const nestedDisplay = isRecord(source.display) ? source.display : null
const nestedStandings = isRecord(source.standings) ? source.standings : null
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
const name = firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name) ?? '未命名赛事'
const subtitle = emptyToNull(
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
)
const host = emptyToNull(firstString(isFlat ? source['event.organizer'] : undefined, nestedEvent?.host))
const logoPath = emptyToNull(
firstString(isFlat ? source['event.logo_path'] : undefined, nestedEvent?.logoPath),
)
const primaryColor = emptyToNull(
firstString(isFlat ? source['display.theme_color'] : undefined, nestedEvent?.primaryColor),
)
const displayLogo = emptyToNull(
firstString(isFlat ? source['display.logo'] : undefined, nestedDisplay?.logo),
)
return {
event: { name, subtitle, host, logoPath, primaryColor },
display: {
background: emptyToNull(
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
),
logo: displayLogo ?? logoPath,
title: firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title) ?? name,
subtitle:
emptyToNull(
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
) ?? subtitle,
primaryColor,
rotateSeconds: firstNumber(
15,
isFlat ? source['display.rotate_seconds'] : undefined,
nestedDisplay?.rotateSeconds,
),
},
standings: {
individualToTeamWeight: firstNumber(
1,
isFlat ? source['standings.individual_to_team_weight'] : undefined,
nestedStandings?.individualToTeamWeight,
),
rankingMode:
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode) ??
'points',
showIndividualBoard: firstBool(
true,
isFlat ? source['standings.show_individual_board'] : undefined,
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualBoard,
),
},
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
requireApproval: firstBool(
false,
isFlat ? source['results.review_required'] : undefined,
source.requireApproval,
),
}
}
/**
* 总积分榜归一化。
*
* 快照用 domain.StandingsRow(`code/name/points/medals`),
* REST 用 standingRowDTO(`teamCode/teamName/totalPoints/medalTotal`),两种都接受。
*/
export function normalizeStandingRows(raw: unknown): StandingRow[] {
if (!Array.isArray(raw)) return []
return raw.map(normalizeStandingRow)
}
function normalizeStandingRow(raw: unknown): StandingRow {
const rec = asRecord(raw)
const gold = firstNumber(0, rec.gold)
const silver = firstNumber(0, rec.silver)
const bronze = firstNumber(0, rec.bronze)
const rankChange = rec.rankChange
return {
rank: firstNumber(0, rec.rank),
teamId: firstNumber(0, rec.teamId),
teamCode: firstString(rec.teamCode, rec.code) ?? '',
teamName: firstString(rec.teamName, rec.name) ?? '',
shortName: emptyToNull(firstString(rec.shortName)),
color: emptyToNull(firstString(rec.color)),
logoPath: emptyToNull(firstString(rec.logoPath)),
gold,
silver,
bronze,
medalTotal: firstNumber(gold + silver + bronze, rec.medalTotal, rec.medals),
totalPoints: firstNumber(0, rec.totalPoints, rec.points),
rankChange: typeof rankChange === 'number' && Number.isFinite(rankChange) ? rankChange : null,
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 登录态判定。
*
* 服务端 `/auth/me` 未登录时返回 200 + guest DTO(role = "guest"、username = "guest"),
* 而不是 401,所以「user 是否为 null」判不出访客;统一走这里的判定函数。
*/
import type { CurrentUser } from './types'
/** 服务端 auth.RoleGuest 的字符串值。 */
export const GUEST_ROLE = 'guest'
export function isGuestRole(role: unknown): boolean {
return typeof role === 'string' && role.trim().toLowerCase() === GUEST_ROLE
}
/** null(还没探测 / 探测失败)与 guest DTO 都算访客。 */
export function isGuestUser(user: CurrentUser | null | undefined): boolean {
return user === null || user === undefined || isGuestRole(user.role)
}
+15 -3
View File
@@ -383,6 +383,11 @@ export interface CurrentUser {
username: string
displayName: string
role: string
/**
* 由客户端按 role 补出:true 表示这是未登录时 /auth/me 返回的 guest DTO。
* 服务端未登录也回 200,不能用「对象是否为 null」判访客,见 shared/session.ts。
*/
guest?: boolean
/** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */
mustChangePassword: boolean
lastLoginAt: string | null
@@ -415,7 +420,7 @@ export interface HealthInfo {
// 规则包(见 02-积分与排名规则 第 7 节)
// ---------------------------------------------------------------------------
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode'
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' | 'name'
export interface RulePackMeta {
code: string
@@ -431,7 +436,8 @@ export interface RulePackStandings {
tieBreak: TieBreakKey[]
medalCountsTeamEvents: boolean
showIndividualBoard: boolean
showIndividualMedals: boolean
/** 服务端规则包会带出该开关;缺省视为 true。 */
showIndividualMedals?: boolean
}
export interface RuleProfile {
@@ -440,6 +446,8 @@ export interface RuleProfile {
awardRanks: number
lastPlaceZero: boolean
tieMode: TieMode
/** 单个模板可覆盖破纪录加分口径;服务端 Canonical 后总会带出。 */
recordBonusMode?: RecordBonusMode
rules: ScoringProfileRule[]
}
@@ -465,11 +473,13 @@ export interface RuleChannel {
scope: ChannelScope
basis: ChannelBasis
profile: string
/** basis 为 aggregate 时的汇总口径(avg/max/min/count),服务端缺省 sum。 */
aggregate?: string
}
export interface ChannelTemplateMatch {
eventType?: EventType
eventCode?: string
resultType?: ResultType
sportCode?: string
}
@@ -482,6 +492,8 @@ export interface EventOverride {
eventCode: string
name?: string
scored?: boolean
/** 单个项目的计分权重覆盖;缺省表示不动。 */
multiplier?: number
channels?: RuleChannel[]
}
+417
View File
@@ -0,0 +1,417 @@
/**
* 极简 YAML 子集解析器。
*
* `GET /api/rules/export` 返回的是服务端 `rules.Marshal` 生成的 YAML 文本,
* 而前端不引入 YAML 依赖,所以这里只实现规则包实际用到的语法:
* 块状映射 / 序列、流式 `[]` / `{}`、单双引号、块标量、数字 / 布尔 / null 标量。
* 解析失败一律抛 `YamlParseError`,由调用方转成 PARSE_FAILED 并带上原文。
*/
export class YamlParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'YamlParseError'
}
}
interface Line {
indent: number
text: string
}
interface Parsed {
value: unknown
next: number
}
export function parseYaml(text: string): unknown {
const lines = tokenize(text)
if (lines.length === 0) return null
const parsed = parseBlock(lines, 0, lines[0].indent)
if (parsed.next < lines.length) {
throw new YamlParseError(`第 ${parsed.next + 1} 行无法归入任何块:${lines[parsed.next].text}`)
}
return parsed.value
}
function tokenize(text: string): Line[] {
const out: Line[] = []
const raw = text.split(/\r?\n/)
for (const line of raw) {
const withoutComment = stripComment(line)
if (withoutComment.trim() === '') continue
// 规则包只允许一个 YAML 文档,遇到分隔符直接跳过而不是当成新文档。
if (/^\s*(---|\.\.\.)\s*$/.test(withoutComment)) continue
out.push({
indent: withoutComment.length - withoutComment.trimStart().length,
text: withoutComment.trim(),
})
}
return out
}
function stripComment(line: string): string {
let quote: '"' | "'" | null = null
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (quote === '"') {
if (ch === '\\') i++
else if (ch === '"') quote = null
continue
}
if (quote === "'") {
if (ch === "'") {
if (line[i + 1] === "'") i++
else quote = null
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
// YAML 注释必须前面是空白(`a#b` 里的 # 属于标量)。
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
}
return line
}
function parseBlock(lines: Line[], index: number, indent: number): Parsed {
const line = lines[index]
if (line === undefined || line.indent < indent) return { value: null, next: index }
if (isSequenceItem(line.text)) return parseSequence(lines, index, line.indent)
return parseMapping(lines, index, line.indent)
}
function parseMapping(lines: Line[], index: number, indent: number): Parsed {
const out: Record<string, unknown> = {}
let i = index
while (i < lines.length) {
const line = lines[i]
if (line.indent !== indent || isSequenceItem(line.text)) break
const entry = splitEntry(line.text)
if (entry === null) throw new YamlParseError(`第 ${i + 1} 行不是「键: 值」:${line.text}`)
if (entry.rest === '') {
const next = lines[i + 1]
if (next !== undefined && next.indent > indent) {
const child = parseBlock(lines, i + 1, indent + 1)
out[entry.key] = child.value
i = child.next
} else {
out[entry.key] = null
i++
}
continue
}
if (isBlockScalarHeader(entry.rest)) {
const scalar = readBlockScalar(lines, i + 1, indent, entry.rest)
out[entry.key] = scalar.value
i = scalar.next
continue
}
out[entry.key] = parseScalar(entry.rest)
i++
}
return { value: out, next: i }
}
function parseSequence(lines: Line[], index: number, indent: number): Parsed {
const out: unknown[] = []
let i = index
while (i < lines.length) {
const line = lines[i]
if (line.indent !== indent || !isSequenceItem(line.text)) break
const afterDash = line.text.slice(1)
const lead = afterDash.length - afterDash.trimStart().length
const rest = afterDash.trimStart()
if (rest === '') {
const next = lines[i + 1]
if (next !== undefined && next.indent > indent) {
const child = parseBlock(lines, i + 1, indent + 1)
out.push(child.value)
i = child.next
} else {
out.push(null)
i++
}
continue
}
if (isBlockScalarHeader(rest)) {
const scalar = readBlockScalar(lines, i + 1, indent, rest)
out.push(scalar.value)
i = scalar.next
continue
}
if (isSequenceItem(rest) || splitEntry(rest) !== null) {
// `- key: value` 把映射的起始写在同一行:造一条等价的虚拟行再按同一份行数组解析,
// 这样后续与虚拟行同缩进的键仍能正确归入本项。
const effectiveIndent = indent + 1 + lead
const virtual = lines.slice()
virtual[i] = { indent: effectiveIndent, text: rest }
const child = isSequenceItem(rest)
? parseSequence(virtual, i, effectiveIndent)
: parseMapping(virtual, i, effectiveIndent)
out.push(child.value)
i = child.next
continue
}
out.push(parseScalar(rest))
i++
}
return { value: out, next: i }
}
function isSequenceItem(text: string): boolean {
return text === '-' || text.startsWith('- ') || text.startsWith('-\t')
}
/** 找顶层的「键: 值」分隔冒号;引号内和流式括号内的冒号不算。 */
function splitEntry(text: string): { key: string; rest: string } | null {
let quote: '"' | "'" | null = null
let depth = 0
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (quote === '"') {
if (ch === '\\') i++
else if (ch === '"') quote = null
continue
}
if (quote === "'") {
if (ch === "'") {
if (text[i + 1] === "'") i++
else quote = null
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
if (ch === '{' || ch === '[') {
depth++
continue
}
if (ch === '}' || ch === ']') {
depth--
continue
}
if (ch === ':' && depth === 0) {
const after = text[i + 1]
if (after === undefined || after === ' ' || after === '\t') {
return {
key: unquote(text.slice(0, i).trim()),
rest: text.slice(i + 1).trim(),
}
}
}
}
return null
}
function isBlockScalarHeader(text: string): boolean {
return /^[|>][+-]?\d*$/.test(text)
}
function readBlockScalar(
lines: Line[],
index: number,
parentIndent: number,
header: string,
): { value: string; next: number } {
const collected: string[] = []
let i = index
while (i < lines.length && lines[i].indent > parentIndent) {
collected.push(lines[i].text)
i++
}
if (collected.length === 0) return { value: '', next: i }
const folded = header.startsWith('>')
const keep = header.includes('+')
const joined = folded ? collected.join(' ') : collected.join('\n')
return { value: keep ? `${joined}\n` : joined, next: i }
}
function parseScalar(text: string): unknown {
const trimmed = text.trim()
if (trimmed === '') return null
if (trimmed.startsWith('"')) return parseDoubleQuoted(trimmed)
if (trimmed.startsWith("'")) return parseSingleQuoted(trimmed)
if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parseFlow(trimmed)
return parsePlain(trimmed)
}
function parseDoubleQuoted(text: string): string {
let out = ''
let i = 1
while (i < text.length) {
const ch = text[i]
if (ch === '\\') {
const esc = text[i + 1]
i += 2
if (esc === 'n') out += '\n'
else if (esc === 't') out += '\t'
else if (esc === 'r') out += '\r'
else if (esc === 'u') {
const hex = text.slice(i, i + 4)
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new YamlParseError(`非法 \\u 转义:${hex}`)
out += String.fromCharCode(parseInt(hex, 16))
i += 4
} else out += esc ?? ''
continue
}
if (ch === '"') {
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`双引号后有多余内容:${text}`)
return out
}
out += ch
i++
}
throw new YamlParseError(`双引号未闭合:${text}`)
}
function parseSingleQuoted(text: string): string {
let out = ''
let i = 1
while (i < text.length) {
const ch = text[i]
if (ch === "'") {
if (text[i + 1] === "'") {
out += "'"
i += 2
continue
}
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`单引号后有多余内容:${text}`)
return out
}
out += ch
i++
}
throw new YamlParseError(`单引号未闭合:${text}`)
}
function parseFlow(text: string): unknown {
let pos = 0
function skip(): void {
while (pos < text.length && /\s/.test(text[pos])) pos++
}
function value(): unknown {
skip()
const ch = text[pos]
if (ch === '[') return sequence()
if (ch === '{') return mapping()
if (ch === '"' || ch === "'") return quoted()
return plain()
}
function quoted(): string {
const start = pos
const quote = text[pos]
pos++
while (pos < text.length) {
const ch = text[pos]
if (quote === '"' && ch === '\\') {
pos += 2
continue
}
if (ch === quote) {
if (quote === "'" && text[pos + 1] === "'") {
pos += 2
continue
}
pos++
const parsed = parseScalar(text.slice(start, pos))
return typeof parsed === 'string' ? parsed : String(parsed ?? '')
}
pos++
}
throw new YamlParseError(`流式引号未闭合:${text}`)
}
function plain(): unknown {
const start = pos
while (pos < text.length && text[pos] !== ',' && text[pos] !== ']' && text[pos] !== '}') pos++
return parsePlain(text.slice(start, pos).trim())
}
function sequence(): unknown[] {
pos++
const out: unknown[] = []
skip()
if (text[pos] === ']') {
pos++
return out
}
while (pos < text.length) {
out.push(value())
skip()
if (text[pos] === ',') {
pos++
continue
}
if (text[pos] === ']') {
pos++
return out
}
throw new YamlParseError(`流式序列缺少逗号或右括号:${text}`)
}
throw new YamlParseError(`流式序列未闭合:${text}`)
}
function mapping(): Record<string, unknown> {
pos++
const out: Record<string, unknown> = {}
skip()
if (text[pos] === '}') {
pos++
return out
}
while (pos < text.length) {
skip()
const keyStart = pos
while (pos < text.length && text[pos] !== ':') pos++
if (pos >= text.length) throw new YamlParseError(`流式映射缺少冒号:${text}`)
const key = unquote(text.slice(keyStart, pos).trim())
pos++
out[key] = value()
skip()
if (text[pos] === ',') {
pos++
continue
}
if (text[pos] === '}') {
pos++
return out
}
throw new YamlParseError(`流式映射缺少逗号或右括号:${text}`)
}
throw new YamlParseError(`流式映射未闭合:${text}`)
}
const result = value()
skip()
if (pos !== text.length) throw new YamlParseError(`流式值后有多余内容:${text}`)
return result
}
function parsePlain(text: string): unknown {
if (text === '' || text === '~' || text === 'null' || text === 'Null' || text === 'NULL') return null
if (/^(true|false)$/i.test(text)) return text.toLowerCase() === 'true'
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
return text
}
function unquote(text: string): string {
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
try {
return parseDoubleQuoted(text)
} catch {
return text.slice(1, -1)
}
}
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
return text.slice(1, -1).replace(/''/g, "'")
}
return text
}