refactor(契约): 判题状态码收进契约唯一一份,并收紧 18 处 as any

- 状态码常量与 judgeStatusSchema 移到 packages/contract/src/judge-status.ts,
  后端 judge/status.ts 只再导出;前端 SubmissionStatus 枚举加编译期断言对齐契约
  (实测改坏一个码会当场类型检查失败)
- 类型逃逸 22 处降到 4 处:collab/handler、pagination、configUpdate、
  ExerciseManager、ProblemSubmission、pk.vue tooltip;剩下的是词云插件无类型、
  生成的 .d.ts、skulpt 和 TextEditor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 04:40:30 -06:00
parent f90d01338e
commit b5ba56ccd0
11 changed files with 77 additions and 51 deletions

View File

@@ -391,7 +391,7 @@ function typeTagType(type: ExerciseType) {
{{ typeName(ex.type) }}
</n-tag>
<n-text style="margin-left: 10px">
{{ (ex.data as any).question }}
{{ (ex.data as { question?: string }).question }}
</n-text>
</div>
<n-space :size="8">

View File

@@ -26,6 +26,7 @@ import {
Legend,
Colors,
Filler,
type TooltipItem,
} from "chart.js"
// 注册Chart.js组件
@@ -673,9 +674,12 @@ const radarChartOptions = {
},
tooltip: {
callbacks: {
label: function (context: any) {
const dataset = context.dataset as any
const rawValue = dataset?.rawData?.[context.dataIndex]
label: function (context: TooltipItem<"radar">) {
// rawData 是我们自己塞进 dataset 的扩展字段chart.js 的类型里没有
const dataset = context.dataset as typeof context.dataset & {
rawData?: (number | null)[]
}
const rawValue = dataset.rawData?.[context.dataIndex]
const metric = context.label || ""
const isRate = context.dataIndex >= 3
if (rawValue === undefined || rawValue === null) {

View File

@@ -298,7 +298,7 @@ watch(query, listSubmissions)
<n-tag
v-for="item in statusDistribution"
:key="item.result"
:type="item.type as any"
:type="item.type"
size="small"
round
>

View File

@@ -19,7 +19,8 @@ export function useConfigUpdate() {
const handleConfigUpdate = (data: ConfigUpdate) => {
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return
;(configStore.config as any)[data.key] = data.value
;(configStore.config as unknown as Record<string, unknown>)[data.key] =
data.value
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (data.key === "websiteName") document.title = data.value
}

View File

@@ -42,11 +42,13 @@ export function usePagination<T extends Record<string, any>>(
limit: parseInt(<string>route.query.limit) || defaultLimit,
...initialQuery,
}) as unknown as T & PaginationQuery
// 键是运行时按 initialQuery 枚举出来的,静态类型写不出来;写入统一走这一个口子
const writable = query as Record<string, unknown>
// 同步 URL 查询参数到本地状态
function syncFromRoute() {
;(query as any).page = parseInt(<string>route.query.page) || defaultPage
;(query as any).limit = parseInt(<string>route.query.limit) || defaultLimit
writable.page = parseInt(<string>route.query.page) || defaultPage
writable.limit = parseInt(<string>route.query.limit) || defaultLimit
// 同步其他查询参数
Object.keys(initialQuery).forEach((key) => {
@@ -54,11 +56,11 @@ export function usePagination<T extends Record<string, any>>(
if (value !== undefined) {
// 处理不同类型的参数
if (typeof initialQuery[key] === "boolean") {
;(query as any)[key] = value === "1" || value === "true"
writable[key] = value === "1" || value === "true"
} else if (typeof initialQuery[key] === "number") {
;(query as any)[key] = parseInt(<string>value) || initialQuery[key]
writable[key] = parseInt(<string>value) || initialQuery[key]
} else {
;(query as any)[key] = <string>value || initialQuery[key]
writable[key] = <string>value || initialQuery[key]
}
}
})
@@ -75,7 +77,7 @@ export function usePagination<T extends Record<string, any>>(
// 重置页码到第一页
function resetPage() {
;(query as any).page = defaultPage
writable.page = defaultPage
}
// 清空所有查询条件(除了分页参数)
@@ -83,13 +85,13 @@ export function usePagination<T extends Record<string, any>>(
Object.keys(initialQuery).forEach((key) => {
const initialValue = initialQuery[key]
if (typeof initialValue === "string") {
;(query as any)[key] = ""
writable[key] = ""
} else if (typeof initialValue === "boolean") {
;(query as any)[key] = false
writable[key] = false
} else if (typeof initialValue === "number") {
;(query as any)[key] = 0
writable[key] = 0
} else {
;(query as any)[key] = initialValue
writable[key] = initialValue
}
})
resetPage()

View File

@@ -1,3 +1,4 @@
import type { JudgeStatusValue } from "@oj2/contract"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐submitting 除外,见下)。
@@ -21,6 +22,18 @@ export enum SubmissionStatus {
ast_check_failed = 10,
}
// 编译期对齐契约:契约加/改一个码而这里没跟,下面两行会当场编译不过。
type SyncedWithContract =
Exclude<
`${SubmissionStatus}`,
`${SubmissionStatus.submitting}`
> extends `${JudgeStatusValue}`
? `${JudgeStatusValue}` extends `${Exclude<SubmissionStatus, SubmissionStatus.submitting>}`
? true
: never
: never
export const _submissionStatusSynced: SyncedWithContract = true
export enum ContestStatus {
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = "1",