feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)

- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
AskaEth committed 2026-09-09 18:36:40 +08:00
1 parent a61d793d79
commit 7d5d94f815
76 files changed
+10131

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const health = ref<string>('检测中…')
onMounted(async () => {
try {
const res = await fetch('/api/health')
const body = (await res.json()) as { ok: boolean; version: string }
health.value = body.ok ? `服务正常 · ${body.version}` : '服务异常'
} catch {
health.value = '无法连接服务端'
}
})
</script>
<template>
<main class="panel">
<header>
<h1>管理面板</h1>
<span class="status">{{ health }}</span>
</header>
<section class="card">
<h2>骨架已就绪</h2>
<p>MIUIx 风格与业务页面将在 M4 实现。</p>
</section>
</main>
</template>
<style scoped>
.panel {
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
align-items: baseline;
gap: 20px;
}
h1 {
font-size: 24px;
}
.status {
font-size: 13px;
color: var(--text-dim);
}
.card {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 24px 28px;
}
h2 {
font-size: 18px;
margin-bottom: 8px;
color: var(--cyan);
}
p {
font-size: 14px;
color: var(--text-dim);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
// 占位数据:M1/M3 接入 /api 后替换为实时数据
const teams = ref([
{ rank: 1, name: '第一分公司', points: 186 },
{ rank: 2, name: '机关本部', points: 172 },
{ rank: 3, name: '第二分公司', points: 158 },
{ rank: 4, name: '第三分公司', points: 141 },
{ rank: 5, name: '第四分公司', points: 127 },
{ rank: 6, name: '第五分公司', points: 103 },
{ rank: 7, name: '第六分公司', points: 95 },
])
const scale = ref(1)
const stage = ref<HTMLElement | null>(null)
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
scale.value = s
if (stage.value) {
stage.value.style.left = `${(window.innerWidth - 1920 * s) / 2}px`
stage.value.style.top = `${(window.innerHeight - 1080 * s) / 2}px`
}
}
onMounted(() => {
fit()
window.addEventListener('resize', fit)
})
onUnmounted(() => window.removeEventListener('resize', fit))
</script>
<template>
<div ref="stage" class="stage" :style="{ transform: `scale(${scale})` }">
<header class="head">
<div class="logo">会标</div>
<h1>赛事积分榜</h1>
<div class="spacer" />
</header>
<section class="board">
<h2>总积分榜</h2>
<table>
<thead>
<tr>
<th class="rk">名次</th>
<th>单位</th>
<th class="pt">总分</th>
</tr>
</thead>
<tbody>
<tr v-for="t in teams" :key="t.name">
<td class="rk">{{ t.rank }}</td>
<td class="nm">{{ t.name }}</td>
<td class="pt">{{ t.points }}</td>
</tr>
</tbody>
</table>
</section>
<footer class="ticker">底栏信息条(轮播占位)</footer>
</div>
</template>
<style scoped>
.stage {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
transform-origin: top left;
padding: 40px 48px;
display: flex;
flex-direction: column;
gap: 24px;
background:
radial-gradient(ellipse at 20% 0%, rgba(79, 216, 255, 0.12), transparent 55%),
radial-gradient(ellipse at 85% 100%, rgba(255, 215, 94, 0.08), transparent 50%), var(--bg);
cursor: none;
}
.head {
display: flex;
align-items: center;
gap: 28px;
}
.logo {
width: 96px;
height: 96px;
border-radius: 50%;
border: 3px solid var(--gold);
display: grid;
place-items: center;
font-size: 22px;
color: var(--text-dim);
}
h1 {
font-size: 64px;
letter-spacing: 8px;
background: linear-gradient(180deg, #fff 30%, var(--gold) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.spacer {
flex: 1;
}
.board {
flex: 1;
min-height: 0;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 20px 28px 24px;
display: flex;
flex-direction: column;
}
h2 {
font-size: 34px;
letter-spacing: 4px;
color: var(--cyan);
padding-bottom: 10px;
border-bottom: 2px solid rgba(79, 216, 255, 0.35);
margin-bottom: 8px;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
font-size: 30px;
padding: 12px 8px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
th {
font-size: 20px;
color: var(--text-dim);
font-weight: 400;
letter-spacing: 2px;
}
td.rk {
width: 140px;
font-weight: 800;
font-size: 34px;
}
tbody tr:nth-child(1) td.rk {
color: var(--gold);
}
tbody tr:nth-child(2) td.rk {
color: #d8e2ee;
}
tbody tr:nth-child(3) td.rk {
color: #e0995e;
}
td.nm {
text-align: left;
padding-left: 32px;
}
td.pt {
width: 220px;
color: var(--gold);
font-weight: 800;
font-size: 36px;
}
.ticker {
height: 88px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: grid;
place-items: center;
font-size: 26px;
color: var(--text-dim);
letter-spacing: 2px;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
const entries = [
{ path: '/display', title: '实时积分大屏', desc: '只读、无人值守;成绩发布后 1 秒内更新' },
{ path: '/admin', title: '管理面板', desc: '录入、发布成绩,控制大屏' },
]
</script>
<template>
<main class="nav">
<h1>赛事积分榜系统</h1>
<div class="cards">
<a v-for="e in entries" :key="e.path" class="card" :href="e.path">
<span class="title">{{ e.title }}</span>
<span class="desc">{{ e.desc }}</span>
<code>{{ e.path }}</code>
</a>
</div>
</main>
</template>
<style scoped>
.nav {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 48px;
}
h1 {
font-size: 32px;
letter-spacing: 2px;
}
.cards {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.card {
width: 320px;
padding: 28px 32px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease;
}
.card:hover {
border-color: var(--cyan);
}
.title {
font-size: 22px;
font-weight: 700;
color: var(--gold);
}
.desc {
font-size: 14px;
color: var(--text-dim);
line-height: 1.6;
}
code {
font-size: 13px;
color: var(--cyan);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+579
View File
@@ -0,0 +1,579 @@
import { isRecord, readString } from './internal'
import type {
AdjustmentInput,
ApiErrorCode,
Athlete,
AthleteInput,
AthleteStanding,
AuditLog,
BackupResult,
CurrentUser,
DisplayCommand,
EntryImportResult,
EntryWithResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
EventSettings,
HealthInfo,
Page,
PageQuery,
PointAdjustment,
PublishPayload,
RecordBreak,
RecordBreakInput,
RecordEntry,
Result,
ResultQuery,
RulePack,
RulePackImportPreview,
RulePackImportResult,
SaveDraftPayload,
ScoreChannel,
ScoreChannelInput,
ScoringProfile,
ScoringProfileInput,
Sport,
SportInput,
StandingRow,
Team,
TeamBreakdown,
TeamInput,
VoidResultPayload,
} from './types'
export type QueryValue = string | number | boolean | null | undefined
export type QueryParams = Record<string, QueryValue | QueryValue[]>
export interface ApiClientOptions {
/** 基础路径,默认 `/api`;Electron 宿主或反向代理下可传绝对地址。 */
baseUrl?: string
/** 会话是 HttpOnly Cookie,默认 `include` 才能带上。 */
credentials?: RequestCredentials
/** CSRF Token 来源;默认读非 HttpOnly 的 csrf_token Cookie(登录时下发)。 */
csrfToken?: () => string | null
/** 单次请求超时;现场局域网也可能出现半开连接,必须能主动放弃。 */
timeoutMs?: number
/** 幂等键生成器;重试写操作时复用同一个 requestId,服务端据此返回首次结果。 */
requestId?: () => string
onUnauthorized?: (error: ApiError) => void
fetchImpl?: typeof fetch
}
interface RequestOptions {
method?: HttpMethod
/** 传接口对象即可;接口没有索引签名,所以这里用 object 接收后运行时收窄。 */
query?: object
body?: unknown
signal?: AbortSignal
}
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
/** 统一响应约定见 05-接口与实时协议 第 6 节。 */
export class ApiError extends Error {
readonly code: ApiErrorCode
readonly status: number
readonly detail: unknown
constructor(code: ApiErrorCode, message: string, status: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.code = code
this.status = status
this.detail = detail
}
}
const API_ERROR_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isApiErrorCode(value: unknown): value is ApiErrorCode {
return typeof value === 'string' && API_ERROR_CODES.includes(value)
}
/** 组别 / 通道随小项整体保存,避免只写了一半的配置被当成有效配置。 */
export interface CrudApi<T, Input> {
list(): Promise<T[]>
get(id: number): Promise<T>
create(input: Input): Promise<T>
update(id: number, input: Partial<Input>): Promise<T>
remove(id: number): Promise<void>
}
export interface AuthApi {
login(username: string, password: string): Promise<CurrentUser>
logout(): Promise<void>
me(): Promise<CurrentUser>
}
export interface SettingsApi {
get(): Promise<EventSettings>
update(patch: Partial<EventSettings>): Promise<EventSettings>
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
channels(eventId: number): Promise<ScoreChannel[]>
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
}
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
}
export interface ResultsApi {
list(query: ResultQuery): Promise<Page<Result>>
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result>
}
export interface StandingsApi {
teams(): Promise<StandingRow[]>
athletes(): Promise<AthleteStanding[]>
breakdown(teamId: number): Promise<TeamBreakdown>
}
export interface AdjustmentsApi {
list(query?: PageQuery & { teamId?: number }): Promise<Page<PointAdjustment>>
create(input: AdjustmentInput): Promise<PointAdjustment>
void(id: number, reason: string): Promise<PointAdjustment>
}
export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
}
export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
}
export interface ExportQuery {
eventId?: number
teamId?: number
}
export interface ExportApi {
/** 返回可直接放进 <a download> 的地址,省掉一次内存中的 Blob。 */
standingsUrl(query?: ExportQuery): string
breakdownUrl(query: ExportQuery): string
resultsUrl(query?: ExportQuery): string
standings(query?: ExportQuery): Promise<Blob>
breakdown(query: ExportQuery): Promise<Blob>
results(query?: ExportQuery): Promise<Blob>
save(blob: Blob, filename: string): void
}
export interface AuditApi {
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
}
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
preview(pack: RulePack): Promise<RulePackImportPreview>
apply(pack: RulePack): Promise<RulePackImportResult>
}
export interface SystemApi {
backup(): Promise<BackupResult>
}
interface Http {
request<T>(path: string, options?: RequestOptions): Promise<T>
requestVoid(path: string, options?: RequestOptions): Promise<void>
requestJson(path: string, options?: RequestOptions): Promise<unknown>
url(path: string, query?: object): string
blob(path: string, options?: RequestOptions): Promise<Blob>
}
function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
return {
list: () => http.request<T[]>(base),
get: (id) => http.request<T>(`${base}/${id}`),
create: (input) => http.request<T>(base, { method: 'POST', body: input }),
update: (id, input) => http.request<T>(`${base}/${id}`, { method: 'PUT', body: input }),
remove: (id) => http.requestVoid(`${base}/${id}`, { method: 'DELETE' }),
}
}
function createEventsApi(http: Http): EventsApi {
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: (eventId) => http.request<EntryWithResult[]>(`/events/${eventId}/entries`),
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
saveCategories: (eventId, categories) =>
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
method: 'PUT',
body: { categories },
}),
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
saveChannels: (eventId, channels) =>
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
method: 'PUT',
body: { channels },
}),
}
}
function createResultsApi(http: Http): ResultsApi {
return {
list: (query) => http.request<Page<Result>>('/results', { query }),
saveDraft: (payload) => http.request<Result[]>('/results/draft', { method: 'PUT', body: payload }),
publish: (payload) => http.request<Result[]>('/results/publish', { method: 'POST', body: payload }),
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
}
}
function createExportApi(http: Http): ExportApi {
const path = {
standings: '/export/standings.xlsx',
breakdown: '/export/breakdown.xlsx',
results: '/export/results.xlsx',
}
return {
standingsUrl: (query) => http.url(path.standings, query),
breakdownUrl: (query) => http.url(path.breakdown, query),
resultsUrl: (query) => http.url(path.results, query),
standings: (query) => http.blob(path.standings, { query }),
breakdown: (query) => http.blob(path.breakdown, { query }),
results: (query) => http.blob(path.results, { query }),
save: (blob, filename) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.rel = 'noopener'
anchor.click()
// 立即 revoke 会在部分浏览器里取消下载,等一帧再释放。
setTimeout(() => URL.revokeObjectURL(url), 0)
},
}
}
function createRulesApi(http: Http): RulesApi {
return {
get: () => http.request<RulePack>('/rules/export'),
preview: (pack) =>
http.request<RulePackImportPreview>('/rules/import', {
method: 'POST',
query: { preview: 1 },
body: pack,
}),
apply: (pack) =>
http.request<RulePackImportResult>('/rules/import', { method: 'POST', body: pack }),
}
}
function readCsrfCookie(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
const raw = match?.[1]
return raw === undefined ? null : decodeURIComponent(raw)
}
function defaultRequestId(): string {
const cryptoApi: Crypto | undefined = globalThis.crypto
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
export class ApiClient {
readonly auth: AuthApi
readonly settings: SettingsApi
readonly teams: CrudApi<Team, TeamInput>
readonly sports: CrudApi<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
readonly athletes: CrudApi<Athlete, AthleteInput>
readonly entries: EntriesApi
readonly results: ResultsApi
readonly standings: StandingsApi
readonly adjustments: AdjustmentsApi
readonly records: RecordsApi
readonly display: DisplayApi
readonly exports: ExportApi
readonly auditLogs: AuditApi
readonly rules: RulesApi
readonly system: SystemApi
private readonly baseUrl: string
private readonly credentials: RequestCredentials
private readonly timeoutMs: number
private readonly csrfToken: () => string | null
private readonly newRequestId: () => string
private readonly onUnauthorized: ((error: ApiError) => void) | undefined
private readonly fetchImpl: typeof fetch
constructor(options: ApiClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? '/api').replace(/\/+$/, '')
this.credentials = options.credentials ?? 'include'
this.timeoutMs = options.timeoutMs ?? 15_000
this.csrfToken = options.csrfToken ?? readCsrfCookie
this.newRequestId = options.requestId ?? defaultRequestId
this.onUnauthorized = options.onUnauthorized
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis)
const http: Http = {
request: (path, requestOptions) => this.request(path, requestOptions),
requestVoid: (path, requestOptions) => this.requestVoid(path, requestOptions),
requestJson: (path, requestOptions) => this.requestJson(path, requestOptions),
url: (path, query) => this.buildUrl(path, query),
blob: (path, requestOptions) => this.fetchBlob(path, requestOptions),
}
this.auth = {
login: (username, password) =>
http.request<CurrentUser>('/auth/login', { method: 'POST', body: { username, password } }),
logout: () => http.requestVoid('/auth/logout', { method: 'POST' }),
me: () => http.request<CurrentUser>('/auth/me'),
}
this.settings = {
get: () => http.request<EventSettings>('/settings'),
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
}
this.teams = createCrudApi<Team, TeamInput>(http, '/teams')
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
this.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
this.entries = {
importFile: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
},
}
this.results = createResultsApi(http)
this.standings = {
teams: () => http.request<StandingRow[]>('/standings'),
athletes: () => http.request<AthleteStanding[]>('/standings/athletes'),
breakdown: (teamId) => http.request<TeamBreakdown>(`/standings/teams/${teamId}/breakdown`),
}
this.adjustments = {
list: (query) => http.request<Page<PointAdjustment>>('/adjustments', { query }),
create: (input) => http.request<PointAdjustment>('/adjustments', { method: 'POST', body: input }),
void: (id, reason) =>
http.request<PointAdjustment>(`/adjustments/${id}/void`, {
method: 'POST',
body: { reason },
}),
}
this.records = {
list: (query) => http.request<RecordEntry[]>('/records', { query }),
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
}
this.display = {
command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }),
}
this.exports = createExportApi(http)
this.auditLogs = {
list: (query) => http.request<Page<AuditLog>>('/audit-logs', { query }),
}
this.rules = createRulesApi(http)
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
}
}
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
async health(): Promise<HealthInfo> {
const body = await this.requestJson('/health')
if (isRecord(body) && body.ok === false) throw this.toApiError(body, 0)
const version = readString(body, 'version')
if (version === null) {
throw new ApiError('PARSE_FAILED', '健康检查响应缺少 version 字段', 0, body)
}
return { version }
}
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const body = await this.requestJson(path, options)
return this.unwrap<T>(body)
}
private async requestVoid(path: string, options: RequestOptions = {}): Promise<void> {
const body = await this.requestJson(path, options)
// 204 或空响应视为成功;有响应体时仍校验 {ok:true}。
if (body === null || body === undefined) return
this.unwrap<unknown>(body)
}
private async requestJson(path: string, options: RequestOptions = {}): Promise<unknown> {
const response = await this.send(path, options)
const body = await readBody(response)
if (response.status === 401) {
const error = this.toApiError(body, response.status)
this.onUnauthorized?.(error)
throw error
}
if (!response.ok) throw this.toApiError(body, response.status)
return body
}
private async fetchBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await this.send(path, options)
if (!response.ok) {
const body = await readBody(response)
if (response.status === 401) {
const error = this.toApiError(body, response.status)
this.onUnauthorized?.(error)
throw error
}
throw this.toApiError(body, response.status)
}
return response.blob()
}
private async send(path: string, options: RequestOptions): Promise<Response> {
const method: HttpMethod = options.method ?? 'GET'
const payload = this.prepareBody(method, options.body)
const headers: Record<string, string> = { Accept: 'application/json' }
if (payload instanceof FormData) {
// 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。
} else if (payload !== undefined) {
headers['Content-Type'] = 'application/json; charset=utf-8'
}
if (method !== 'GET') {
const token = this.csrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
headers['X-Request-Id'] = this.newRequestId()
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
const external = options.signal
if (external !== undefined) {
if (external.aborted) controller.abort()
else external.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
return await this.fetchImpl(this.buildUrl(path, options.query), {
method,
headers,
credentials: this.credentials,
body:
payload === undefined
? undefined
: payload instanceof FormData
? payload
: JSON.stringify(payload),
signal: controller.signal,
})
} catch (error) {
if (controller.signal.aborted) {
throw new ApiError('TIMEOUT', `请求超时(${this.timeoutMs}ms)`, 0, error)
}
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
} finally {
clearTimeout(timer)
}
}
/** 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 */
private prepareBody(method: HttpMethod, body: unknown): unknown {
if (method === 'GET' || body === undefined) return undefined
if (isRecord(body) && !('requestId' in body)) {
return { ...body, requestId: this.newRequestId() }
}
return body
}
private unwrap<T>(body: unknown): T {
if (!isRecord(body) || typeof body.ok !== 'boolean') {
throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', 0, body)
}
if (body.ok) return body.data as T
throw this.toApiError(body, 0)
}
private toApiError(body: unknown, status: number): ApiError {
if (isRecord(body) && isRecord(body.error)) {
const code = body.error.code
const message = readString(body.error, 'message')
return new ApiError(
isApiErrorCode(code) ? code : 'INTERNAL',
message ?? `请求失败(HTTP ${status})`,
status,
body.error.detail,
)
}
return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body)
}
private buildUrl(path: string, query?: object): string {
const base = path.startsWith('/') ? `${this.baseUrl}${path}` : `${this.baseUrl}/${path}`
if (query === undefined || query === null || !isRecord(query)) return base
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
if (isScalar(item)) params.append(key, String(item))
}
continue
}
if (isScalar(value)) params.append(key, String(value))
}
const search = params.toString()
return search === '' ? base : `${base}?${search}`
}
}
function isScalar(value: unknown): value is string | number | boolean {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
async function readBody(response: Response): Promise<unknown> {
if (response.status === 204) return null
const text = await response.text()
if (text.trim() === '') return null
try {
return JSON.parse(text)
} catch {
// 非 JSON 也原样返回,报错里带上原文才定位得到问题。
return text
}
}
export function createApiClient(options: ApiClientOptions = {}): ApiClient {
return new ApiClient(options)
}
+111
View File
@@ -0,0 +1,111 @@
import type { ResultType } from './types'
/**
* 成绩 / 时间的展示与录入互转。
*
* 库里同时存 result_text(给人看)和 result_value(给排序用),
* 两者必须在同一处换算,否则录入页和导出会出现「看起来一样、排出来不一样」。
*/
const DATE_PAD = (value: number): string => String(value).padStart(2, '0')
/** 秒 → `1:06.33`;不足一分钟用 `6.33`。 */
export function formatSeconds(seconds: number, fractionDigits = 2): string {
if (!Number.isFinite(seconds)) return '—'
const sign = seconds < 0 ? '-' : ''
const abs = Math.abs(seconds)
let minutes = Math.floor(abs / 60)
let rest = (abs - minutes * 60).toFixed(fractionDigits)
// 59.999 会四舍五入成 "60.00",进位到分钟,避免出现 "1:60.00"。
if (Number(rest) >= 60) {
minutes += 1
rest = (0).toFixed(fractionDigits)
}
const body = minutes > 0 ? `${minutes}:${rest.padStart(3 + fractionDigits, '0')}` : rest
return `${sign}${body}`
}
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。 */
export function parseSeconds(text: string): number | null {
const trimmed = text.trim()
if (trimmed === '') return null
const parts = trimmed.split(':')
if (parts.length > 2) return null
const numbers: number[] = []
for (const part of parts) {
const value = Number(part)
if (!Number.isFinite(value)) return null
numbers.push(value)
}
if (numbers.length === 1) return numbers[0] ?? null
const minutes = numbers[0] ?? 0
const seconds = numbers[1] ?? 0
if (seconds < 0 || seconds >= 60) return null
return minutes * 60 + seconds
}
/** 按单位把比较值渲染成展示文本,如 `66.33` + `s` → `1:06.33`。 */
export function formatResultText(
value: number | null,
unit: string,
type: ResultType = 'none',
): string {
if (value === null || !Number.isFinite(value)) return '—'
if (type === 'time' || unit === 's') return formatSeconds(value)
if (unit === 'm') return `${value.toFixed(2)}米`
if (unit === 'pts') return `${formatPoints(value)}分`
if (unit === '次') return `${Math.round(value)}次`
return `${formatPoints(value)}${unit}`
}
/** 展示文本 → 比较值,与 formatResultText 互逆。 */
export function parseResultValue(text: string, unit: string): number | null {
const trimmed = text.trim()
if (trimmed === '') return null
if (unit === 's') return parseSeconds(trimmed)
const numeric = Number(trimmed.replace(/[^\d.+-]/g, ''))
return Number.isFinite(numeric) ? numeric : null
}
/** 积分去掉无意义的 `.0`,同时避免 `12.25` 被截成 `12.3`。 */
export function formatPoints(points: number): string {
if (!Number.isFinite(points)) return '—'
return Number.isInteger(points) ? String(points) : String(Number(points.toFixed(2)))
}
/** 名次升降:正数为上升。无变化返回空串,交给调用方决定是否占位。 */
export function formatRankChange(delta: number | null): string {
if (delta === null || delta === 0) return ''
return delta > 0 ? `↑${delta}` : `↓${Math.abs(delta)}`
}
/** ISO → `YYYY-MM-DD HH:mm`(本地时区)。 */
export function formatDateTime(iso: string | null): string {
const date = toDate(iso)
if (date === null) return '—'
return (
`${date.getFullYear()}-${DATE_PAD(date.getMonth() + 1)}-${DATE_PAD(date.getDate())}` +
` ${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}`
)
}
/** ISO → `HH:mm`,大屏顶栏时钟用。 */
export function formatClock(iso: string | null): string {
const date = toDate(iso)
if (date === null) return '--:--'
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}`
}
function toDate(iso: string | null): Date | null {
if (iso === null || iso === '') return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : date
}
+27
View File
@@ -0,0 +1,27 @@
/**
* shared 层统一出口。
*
* 三个入口(index / display / admin)只从这里取公共能力,
* 避免各自 import 到内部文件后形成难以收敛的依赖网。
*/
export * from './types'
export { ApiClient, ApiError, createApiClient } from './api'
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
export { RealtimeClient } from './realtime'
export type {
RealtimeInfo,
RealtimeOptions,
RealtimeStatus,
ReconnectOptions,
} from './realtime'
export {
formatClock,
formatDateTime,
formatPoints,
formatRankChange,
formatResultText,
formatSeconds,
parseResultValue,
parseSeconds,
} from './format'
+16
View File
@@ -0,0 +1,16 @@
/**
* shared 层内部工具,不对外导出。
*
* HTTP / WebSocket 收到的一切都先当 unknown,再用这些收窄函数校验;
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function readString(source: unknown, key: string): string | null {
if (!isRecord(source)) return null
const value = source[key]
return typeof value === 'string' ? value : null
}
+371
View File
@@ -0,0 +1,371 @@
import { isRecord } from './internal'
import type {
CatchupMessage,
ChangeMessage,
ClientMessageData,
ClientRole,
DisplayCommand,
HeartbeatMessage,
HelloMessage,
ServerMessage,
SnapshotMessage,
} from './types'
export type RealtimeStatus = 'idle' | 'connecting' | 'online' | 'offline'
export interface RealtimeInfo {
/** 本地已应用到的版本;null 表示还没拿到快照。 */
version: number | null
/** 连续重连次数,连上后归零。 */
attempt: number
/** 服务端最近一次心跳时间,用于显示时钟偏差。 */
serverTime: string | null
}
export interface ReconnectOptions {
baseDelayMs?: number
maxDelayMs?: number
factor?: number
/** 抖动比例,避免多块大屏同时重连把服务端打满。 */
jitterRatio?: number
}
export interface RealtimeOptions {
/** 默认按当前页面推导 `ws(s)://<host>/ws`;Electron 或反代下可显式指定。 */
url?: string
role: ClientRole
displayKey?: string
/** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */
initialVersion?: number | null
reconnect?: ReconnectOptions
/** 超过该时间没有任何服务端消息即判定半开连接,主动断开重连。 */
watchdogMs?: number
/** resync 最小间隔,避免版本持续落后时反复打断服务端。 */
resyncCooldownMs?: number
onSnapshot?: (snapshot: SnapshotMessage) => void
onCatchup?: (message: CatchupMessage) => void
onChange?: (message: ChangeMessage) => void
onCommand?: (command: DisplayCommand) => void
onStatusChange?: (status: RealtimeStatus, info: RealtimeInfo) => void
onVersionChange?: (version: number) => void
onError?: (error: Error) => void
}
const SERVER_EVENTS: readonly string[] = [
'snapshot',
'catchup',
'change',
'heartbeat',
'display:command',
]
function isServerMessage(value: unknown): value is ServerMessage {
if (!isRecord(value)) return false
const event = value.event
return typeof event === 'string' && SERVER_EVENTS.includes(event) && isRecord(value.data)
}
function defaultUrl(): string {
if (typeof location === 'undefined') return '/ws'
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${location.host}/ws`
}
/**
* 版本号 + 快照/增量的自研实时客户端(05 第 1–5 节)。
*
* 关键约束:变更必须按 version 顺序应用,缺口只靠 resync 补,
* 不允许把乱序的增量直接合并进本地状态。
*/
export class RealtimeClient {
private readonly url: string
private readonly role: ClientRole
private readonly displayKey: string | undefined
private readonly reconnect: Required<ReconnectOptions>
private readonly watchdogMs: number
private readonly resyncCooldownMs: number
private readonly handlers: Pick<
RealtimeOptions,
| 'onSnapshot'
| 'onCatchup'
| 'onChange'
| 'onCommand'
| 'onStatusChange'
| 'onVersionChange'
| 'onError'
>
private socket: WebSocket | null = null
private currentStatus: RealtimeStatus = 'idle'
private currentVersion: number | null
private attempt = 0
private serverTime: string | null = null
private closedByUser = false
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private watchdogTimer: ReturnType<typeof setTimeout> | null = null
private lastResyncAt = 0
constructor(options: RealtimeOptions) {
this.url = options.url ?? defaultUrl()
this.role = options.role
this.displayKey = options.displayKey
this.currentVersion = options.initialVersion ?? null
this.watchdogMs = options.watchdogMs ?? 25_000
this.resyncCooldownMs = options.resyncCooldownMs ?? 3_000
this.reconnect = {
baseDelayMs: options.reconnect?.baseDelayMs ?? 1_000,
maxDelayMs: options.reconnect?.maxDelayMs ?? 30_000,
factor: options.reconnect?.factor ?? 2,
jitterRatio: options.reconnect?.jitterRatio ?? 0.2,
}
this.handlers = {
onSnapshot: options.onSnapshot,
onCatchup: options.onCatchup,
onChange: options.onChange,
onCommand: options.onCommand,
onStatusChange: options.onStatusChange,
onVersionChange: options.onVersionChange,
onError: options.onError,
}
}
get status(): RealtimeStatus {
return this.currentStatus
}
get version(): number | null {
return this.currentVersion
}
get lastServerTime(): string | null {
return this.serverTime
}
connect(): void {
if (typeof WebSocket === 'undefined') {
this.handlers.onError?.(new Error('当前环境不支持 WebSocket'))
return
}
const existing = this.socket
if (existing !== null && existing.readyState !== WebSocket.CLOSED) return
this.closedByUser = false
this.clearReconnectTimer()
this.setStatus('connecting')
let socket: WebSocket
try {
socket = new WebSocket(this.url)
} catch (error) {
this.handlers.onError?.(toError(error))
this.scheduleReconnect()
return
}
this.socket = socket
socket.onopen = () => {
this.attempt = 0
this.setStatus('online')
this.sendHello()
this.armWatchdog()
}
socket.onmessage = (event: MessageEvent<unknown>) => {
this.handleMessage(event.data)
}
socket.onerror = () => {
// 错误之后必然跟一次 close,重连交给 onclose 统一处理。
this.handlers.onError?.(new Error('WebSocket 连接出错'))
}
socket.onclose = () => {
this.socket = null
this.clearWatchdog()
if (!this.closedByUser) this.scheduleReconnect()
}
}
close(): void {
this.closedByUser = true
this.clearReconnectTimer()
this.clearWatchdog()
const socket = this.socket
this.socket = null
if (socket !== null) {
// 先摘掉回调,否则 close() 会触发 onclose 又排一次重连。
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
socket.close()
}
this.setStatus('idle')
}
/** 客户端发现本地版本落后时主动补数据(心跳里 version 更大、或收到无法应用的增量)。 */
requestResync(): void {
const now = Date.now()
if (now - this.lastResyncAt < this.resyncCooldownMs) return
this.lastResyncAt = now
this.send('resync', {})
}
private handleMessage(raw: unknown): void {
if (typeof raw !== 'string') {
this.handlers.onError?.(new Error('收到非文本 WebSocket 消息'))
return
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
this.handlers.onError?.(new Error('WebSocket 消息不是合法 JSON'))
return
}
if (!isServerMessage(parsed)) {
this.handlers.onError?.(new Error('WebSocket 消息缺少 event/data 字段'))
return
}
// 任何服务端消息都算一次心跳,watchdog 只关心「多久没消息」。
this.armWatchdog()
switch (parsed.event) {
case 'snapshot':
this.applySnapshot(parsed.data)
break
case 'catchup':
this.applyCatchup(parsed.data)
break
case 'change':
this.applyChange(parsed.data)
break
case 'heartbeat':
this.applyHeartbeat(parsed.data)
break
case 'display:command':
this.handlers.onCommand?.(parsed.data)
break
}
}
private applySnapshot(data: SnapshotMessage): void {
if (this.currentVersion !== null && data.version < this.currentVersion) return
this.setVersion(data.version)
this.handlers.onSnapshot?.(data)
this.sendAck()
}
private applyCatchup(data: CatchupMessage): void {
if (this.currentVersion !== null && data.version <= this.currentVersion) return
this.setVersion(data.version)
this.handlers.onCatchup?.(data)
this.sendAck()
}
private applyChange(data: ChangeMessage): void {
// 重复或乱序的变更直接丢弃(05 第 5 节)。
if (this.currentVersion !== null && data.version <= this.currentVersion) return
// 还没有快照就收到增量,说明错过了 hello 的响应,先要全量。
if (this.currentVersion === null) {
this.requestResync()
return
}
this.setVersion(data.version)
this.handlers.onChange?.(data)
this.sendAck()
}
private applyHeartbeat(data: HeartbeatMessage): void {
this.serverTime = data.serverTime
// 心跳 version 更大说明本地漏了变更(服务端每 10s 一次),补一发 resync。
if (this.currentVersion === null || data.version > this.currentVersion) {
this.requestResync()
}
}
private setVersion(version: number): void {
if (this.currentVersion === version) return
this.currentVersion = version
this.handlers.onVersionChange?.(version)
}
private sendHello(): void {
const hello: HelloMessage = { role: this.role }
if (this.currentVersion !== null) hello.lastVersion = this.currentVersion
if (this.displayKey !== undefined) hello.displayKey = this.displayKey
this.send('hello', hello)
}
private sendAck(): void {
if (this.currentVersion === null) return
this.send('ack', { version: this.currentVersion })
}
private send<K extends keyof ClientMessageData>(event: K, data: ClientMessageData[K]): void {
const socket = this.socket
if (socket === null || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ event, data }))
}
private armWatchdog(): void {
this.clearWatchdog()
this.watchdogTimer = setTimeout(() => {
this.handlers.onError?.(new Error('心跳超时,判定连接已断开'))
// 主动关闭以触发 onclose 里的重连;半开连接不会自己走到这一步。
this.socket?.close()
}, this.watchdogMs)
}
private clearWatchdog(): void {
if (this.watchdogTimer !== null) {
clearTimeout(this.watchdogTimer)
this.watchdogTimer = null
}
}
private scheduleReconnect(): void {
if (this.closedByUser || this.reconnectTimer !== null) return
this.setStatus('offline')
const delay = this.nextDelay()
this.attempt += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
private nextDelay(): number {
const raw = this.reconnect.baseDelayMs * this.reconnect.factor ** this.attempt
const capped = Math.min(raw, this.reconnect.maxDelayMs)
const jitter = capped * this.reconnect.jitterRatio * (Math.random() * 2 - 1)
return Math.max(0, Math.round(capped + jitter))
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
private setStatus(status: RealtimeStatus): void {
if (this.currentStatus === status) return
this.currentStatus = status
this.handlers.onStatusChange?.(status, this.info())
}
private info(): RealtimeInfo {
return {
version: this.currentVersion,
attempt: this.attempt,
serverTime: this.serverTime,
}
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
+43
View File
@@ -0,0 +1,43 @@
/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */
:root {
--bg: #0b1e3a;
--panel: rgba(255, 255, 255, 0.05);
--panel-border: rgba(120, 180, 255, 0.25);
--gold: #ffd75e;
--cyan: #4fd8ff;
--text: #e8f1ff;
--text-dim: #9db4d4;
--design-w: 1920px;
--design-h: 1080px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family:
'Noto Sans SC', 'Source Han Sans SC', 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+691
View File
@@ -0,0 +1,691 @@
/**
* 后端 API JSON 的领域类型。
*
* 统一 camelCase:服务端负责把 SQLite 的 snake_case 列名映射成 camelCase,
* 前端不再维护第二套命名,避免两处各改一半。
* 布尔字段对应库里的 INTEGER 0/1,服务端序列化为 JSON 布尔值。
*/
export type Gender = '男' | '女'
export interface Team {
id: number
code: string
name: string
shortName: string | null
logoPath: string | null
color: string | null
sortOrder: number
active: boolean
}
export type TeamInput = Omit<Team, 'id'>
export interface Athlete {
id: number
name: string
gender: Gender | null
teamId: number
employeeNo: string | null
active: boolean
}
export type AthleteInput = Omit<Athlete, 'id'>
export interface Sport {
id: number
code: string
name: string
shortName: string | null
defaultProfileId: number | null
sortOrder: number
active: boolean
}
export type SportInput = Omit<Sport, 'id'>
export type EventType = 'individual' | 'team' | 'match'
export type ResultMode = 'auto' | 'manual'
export type ResultType = 'time' | 'distance' | 'count' | 'score' | 'none'
export type CompareMode = 'asc' | 'desc' | 'none'
export type EventStatus = 'pending' | 'ongoing' | 'finished' | 'published'
export interface EventCategory {
id: number
eventId: number
name: string
multiplier: number
gender: Gender | null
ageMin: number | null
ageMax: number | null
sortOrder: number
}
export type EventCategoryInput = Omit<EventCategory, 'id' | 'eventId'>
export type ChannelScope = 'athlete' | 'team'
export type ChannelBasis = 'result' | 'manual' | 'aggregate'
export interface ScoreChannel {
id: number
eventId: number
scope: ChannelScope
basis: ChannelBasis
profileId: number
sortOrder: number
}
export type ScoreChannelInput = Omit<ScoreChannel, 'id' | 'eventId'>
export interface Event {
id: number
sportId: number
code: string
name: string
eventType: EventType
resultMode: ResultMode
resultType: ResultType
compare: CompareMode
recordEligible: boolean
/** false 表示只排名不计分(events.scored = 0)。 */
scored: boolean
multiplier: number
status: EventStatus
scheduledAt: string | null
venue: string | null
sortOrder: number
remark: string | null
/** 详情接口才带出,列表接口可能省略。 */
categories?: EventCategory[]
channels?: ScoreChannel[]
}
export type EventInput = Omit<Event, 'id' | 'categories' | 'channels'>
export type TieMode = 'skip' | 'average' | 'higher'
export type RecordBonusMode = 'max' | 'sum'
export interface ScoringProfileRule {
rank: number
points: number
}
export interface ScoringProfile {
id: number
code: string
name: string
tieMode: TieMode
recordBonusMode: RecordBonusMode
/** 录取名次;文档中由规则包定义,接口可能省略。 */
awardRanks?: number
lastPlaceZero?: boolean
rules: ScoringProfileRule[]
}
export type ScoringProfileInput = Omit<ScoringProfile, 'id'>
export type EntryStatus = 'registered' | 'scratched' | 'dq' | 'dns'
export interface Entry {
id: number
eventId: number
categoryId: number
teamId: number
athleteId: number | null
/** 团体/接力成员;库里是 JSON 数组字符串,服务端解析成数组。 */
memberNames: string[] | null
bib: string | null
seed: string | null
status: EntryStatus
remark: string | null
/** 以下为联表展示字段,接口按需带出。 */
athleteName?: string | null
teamCode?: string
teamName?: string
shortName?: string | null
categoryName?: string
}
export interface EntryWithResult extends Entry {
result: Result | null
}
export interface EntryImportResult {
created: number
updated: number
skipped: number
errors: string[]
}
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
export type ResultState = 'draft' | 'published'
export interface Result {
id: number
eventId: number
entryId: number
rank: number | null
/** 展示用文本,如「1:06.33」「6.12米」。 */
resultText: string
/** 比较用数值(秒/米/分),排序与破纪录判断以它为准。 */
resultValue: number | null
unit: string
isTie: boolean
pointsOverride: number | null
status: ResultStatus
state: ResultState
recordedBy: number | null
recordedAt: string | null
verifiedBy: number | null
verifiedAt: string | null
note: string | null
/** 服务端按计分通道算出的得分,只读;录入页用它做实时预览。 */
points?: number | null
}
export interface DraftResultInput {
entryId: number
rank?: number | null
resultText?: string
resultValue?: number | null
unit?: string
isTie?: boolean
status?: ResultStatus
pointsOverride?: number | null
note?: string | null
}
export interface SaveDraftPayload {
eventId: number
/** 缺省表示整个小项一次保存。 */
categoryId?: number
results: DraftResultInput[]
requestId?: string
}
export interface PublishPayload {
eventId: number
categoryId?: number
requestId?: string
}
export interface VoidResultPayload {
reason: string
requestId?: string
}
export interface ResultQuery extends PageQuery {
eventId?: number
state?: ResultState
}
/** 总榜行的公共部分:单位身份信息。 */
export interface TeamRef {
teamId: number
teamCode: string
teamName: string
shortName: string | null
color: string | null
logoPath: string | null
}
export interface MedalRow extends TeamRef {
rank: number
gold: number
silver: number
bronze: number
medalTotal: number
}
/** 总积分榜行;接口约定「含奖牌数」,所以直接扩展 MedalRow。 */
export interface StandingRow extends MedalRow {
totalPoints: number
/** 相对上一次快照的名次变化:正数上升、负数下降,null 表示无对比基准。 */
rankChange: number | null
}
export interface AthleteStanding {
rank: number
athleteId: number
athleteName: string
gender: Gender | null
teamId: number
teamCode: string
teamName: string
shortName: string | null
totalPoints: number
gold: number
silver: number
bronze: number
}
export type LedgerSourceType = 'result' | 'record_bonus' | 'adjustment'
/** 单位积分明细的一行,用于「可解释每一分」。 */
export interface BreakdownItem {
ledgerId: number
sourceType: LedgerSourceType
eventId: number | null
eventCode: string | null
eventName: string | null
categoryName: string | null
athleteId: number | null
athleteName: string | null
rank: number | null
resultText: string | null
points: number
createdAt: string
}
export interface TeamBreakdown {
teamId: number
teamCode: string
teamName: string
totalPoints: number
items: BreakdownItem[]
}
export type AdjustmentCategory = 'penalty' | 'bonus' | 'correction'
export interface PointAdjustment {
id: number
teamId: number
delta: number
category: AdjustmentCategory
reason: string
eventId: number | null
voided: boolean
createdBy: number | null
createdAt: string
}
export interface AdjustmentInput {
teamId: number
delta: number
category: AdjustmentCategory
reason: string
eventId?: number | null
}
export type RecordLevel = 'meet' | 'city' | 'province' | 'national'
export type RecordSource = 'history' | 'current'
/** 命名避开 TS 内置的 Record<K,V>,同时保留库里 records 表的语义。 */
export interface RecordEntry {
id: number
eventId: number
level: RecordLevel
resultValue: number
resultText: string
holderName: string | null
holderTeamId: number | null
achievedAt: string | null
isCurrent: boolean
source: RecordSource
}
export type RecordBreakState = 'pending' | 'approved' | 'rejected'
export interface RecordBreak {
id: number
eventId: number
entryId: number
level: RecordLevel
resultValue: number
resultText: string
bonusPoints: number
state: RecordBreakState
approvedBy: number | null
approvedAt: string | null
}
export interface RecordBreakInput {
eventId: number
entryId: number
level: RecordLevel
resultValue: number
resultText: string
}
/**
* 赛事配置。服务端以 `a.b` 形式存在 settings 表里(如 `display.rotate_seconds`),
* 接口按前缀聚合成嵌套对象,前端不必再解析点号键。
*/
export interface EventSettings {
event: {
name: string
subtitle: string | null
host: string | null
logoPath: string | null
primaryColor: string | null
}
display: {
background: string | null
logo: string | null
title: string
subtitle: string | null
primaryColor: string | null
rotateSeconds: number
}
standings: {
individualToTeamWeight: number
/** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */
rankingMode: string
showIndividualBoard: boolean
}
defaultProfileId: number | null
requireApproval: boolean
}
/** 角色取值文档未枚举,用 string 避免前后端枚举不同步时误判。 */
export interface CurrentUser {
id: number
username: string
displayName: string
role: string
lastLoginAt: string | null
}
export interface AuditLog {
id: number
userId: number | null
action: string
entityType: string
entityId: number | null
/** 库里是 JSON 文本,前端需要时自行解析。 */
beforeJson: string | null
afterJson: string | null
ip: string | null
requestId: string | null
createdAt: string
}
export interface BackupResult {
path: string
createdAt: string
}
export interface HealthInfo {
version: string
}
// ---------------------------------------------------------------------------
// 规则包(见 02-积分与排名规则 第 7 节)
// ---------------------------------------------------------------------------
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode'
export interface RulePackMeta {
code: string
name: string
version: string
updatedAt: string
author: string | null
note: string | null
}
export interface RulePackStandings {
individualToTeamWeight: number
tieBreak: TieBreakKey[]
medalCountsTeamEvents: boolean
showIndividualBoard: boolean
showIndividualMedals: boolean
}
export interface RuleProfile {
code: string
name: string
awardRanks: number
lastPlaceZero: boolean
tieMode: TieMode
rules: ScoringProfileRule[]
}
export interface RecordBonusLevel {
code: string
name: string
points: number
}
export interface RulePackRecordBonus {
enabled: boolean
mode: RecordBonusMode
levels: RecordBonusLevel[]
}
export interface RulePackManualAdjustment {
enabled: boolean
reasonRequired: boolean
}
/** 规则包里的通道用 profile code 引用模板,而不是数据库 id,便于跨赛事复用。 */
export interface RuleChannel {
scope: ChannelScope
basis: ChannelBasis
profile: string
}
export interface ChannelTemplateMatch {
eventType?: EventType
eventCode?: string
sportCode?: string
}
export interface ChannelTemplate {
match: ChannelTemplateMatch
channels: RuleChannel[]
}
export interface EventOverride {
eventCode: string
name?: string
scored?: boolean
channels?: RuleChannel[]
}
export interface RulePack {
format: 'scoreboard-rules'
formatVersion: number
meta: RulePackMeta
standings: RulePackStandings
profiles: RuleProfile[]
recordBonus: RulePackRecordBonus
manualAdjustment: RulePackManualAdjustment
channelTemplates: ChannelTemplate[]
eventOverrides: EventOverride[]
}
export type RulePackDiffKind = 'profile' | 'channelTemplate' | 'eventOverride' | 'standings'
export type RulePackDiffChange = 'added' | 'removed' | 'modified' | 'unchanged'
export interface RulePackDiffEntry {
kind: RulePackDiffKind
target: string
change: RulePackDiffChange
detail: string
}
/** 导入必须先预览再落地(02 第 7.3 节),所以预览结果是独立类型。 */
export interface RulePackImportPreview {
formatVersion: number
/** false 表示 formatVersion 不兼容,此时不允许落地。 */
compatible: boolean
diffs: RulePackDiffEntry[]
affectedEventCount: number
}
export interface RulePackImportResult extends RulePackImportPreview {
applied: boolean
/** 导入后服务端全量重算的积分流水条数,用于确认重算确实发生。 */
recalculatedLedgerRows: number
}
// ---------------------------------------------------------------------------
// REST 统一响应
// ---------------------------------------------------------------------------
export type ApiErrorCode =
| 'AUTH_REQUIRED'
| 'AUTH_FAILED'
| 'FORBIDDEN'
| 'VALIDATION_FAILED'
| 'CONFLICT'
| 'STATE_INVALID'
| 'RATE_LIMITED'
| 'INTERNAL'
// 以下三个由客户端产生,服务端不会返回,但前端要能统一处理
| 'NETWORK'
| 'TIMEOUT'
| 'PARSE_FAILED'
export interface ApiErrorBody {
code: ApiErrorCode
message: string
detail?: unknown
}
export type ApiResponse<T> = { ok: true; data: T } | { ok: false; error: ApiErrorBody }
export interface PageQuery {
page?: number
size?: number
}
export interface Page<T> {
items: T[]
total: number
page: number
size: number
}
// ---------------------------------------------------------------------------
// 实时协议(见 05-接口与实时协议 第 1–5 节)
// ---------------------------------------------------------------------------
export type ClientRole = 'display' | 'admin'
export interface HelloMessage {
role: ClientRole
/** 上一次已应用的版本,服务端据此决定发增量还是整包快照。 */
lastVersion?: number
displayKey?: string
}
export interface AckMessage {
version: number
}
/** resync 无载荷,用空对象保持与服务端事件格式一致。 */
export type ResyncMessage = Record<string, never>
export type ChangeAction = 'upsert' | 'void' | 'replace'
export interface Change {
entity: string
id?: number
action: ChangeAction
/** 载荷形状随 entity 变化,调用方按 entity 收窄后再使用。 */
data: unknown
}
export interface EventResultRow {
entryId: number
rank: number | null
teamId: number
teamCode: string
teamName: string
shortName: string | null
athleteId: number | null
athleteName: string | null
memberNames: string[] | null
resultText: string
resultValue: number | null
unit: string
isTie: boolean
status: ResultStatus
}
export interface EventResultGroup {
eventId: number
eventCode: string
eventName: string
categoryId: number
categoryName: string
publishedAt: string | null
rows: EventResultRow[]
}
export interface SnapshotMessage {
version: number
settings: EventSettings
teams: Team[]
events: Event[]
standings: StandingRow[]
records: RecordEntry[]
latestResults: EventResultGroup[]
}
export interface CatchupMessage {
fromVersion: number
version: number
changes: Change[]
}
export interface ChangeMessage {
version: number
changes: Change[]
}
export interface HeartbeatMessage {
version: number
serverTime: string
}
export type DisplayAction =
| 'goto'
| 'lock'
| 'unlock'
| 'reload'
| 'announce'
| 'show-detail'
| 'hide-detail'
export interface DisplayCommandPayload {
page?: string
text?: string
/** 公告滚动时长。 */
seconds?: number
autoCloseSeconds?: number
teamId?: number
title?: string
/** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */
detail?: TeamBreakdown
}
export interface DisplayCommand {
action: DisplayAction
payload: DisplayCommandPayload
}
export interface ServerMessageData {
snapshot: SnapshotMessage
catchup: CatchupMessage
change: ChangeMessage
heartbeat: HeartbeatMessage
'display:command': DisplayCommand
}
export interface ClientMessageData {
hello: HelloMessage
ack: AckMessage
resync: ResyncMessage
}
export type ServerEvent = keyof ServerMessageData
export type ClientEvent = keyof ClientMessageData
export type ServerMessageOf<K extends ServerEvent> = { event: K; data: ServerMessageData[K] }
export type ClientMessageOf<K extends ClientEvent> = { event: K; data: ClientMessageData[K] }
export type ServerMessage = { [K in ServerEvent]: ServerMessageOf<K> }[ServerEvent]
export type ClientMessage = { [K in ClientEvent]: ClientMessageOf<K> }[ClientEvent]