Files
OJ2/packages/contract/src/content.ts
yuetsh 5c772319e9
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(契约): 公告两侧形状分开,判题状态码从契约派生
## 公告:一个 schema 兼两种形态,兼出两处谎

前端 utils/types.ts 只手抄了**后台那份**公告形状,oj 侧也拿它当类型用。可
apps/api/src/routes/content.ts 的 `/announcements` 列表既不下发 content 也不
下发 visible —— 于是类型声称列表行有这两个键、运行时都是 undefined。今天没炸
只是因为组件恰好没读(正文是点开后另拉一次详情)。

契约那边则是另一头:announcementSchema 把 content 写成 `.optional()`,让一个
schema 同时兼列表和详情。代价是**详情**拿到的 content 类型也成了
`string | undefined`,组件只能 ?? 兜底。

现在两边都按后台侧早就在用的套路拆开:content 必填,列表用
`.omit({ content: true })` 派生。四个类型各归各位:

  oj 列表   AnnouncementListItem        无 content、无 visible
  oj 详情   Announcement                有 content、无 visible
  后台列表  AdminAnnouncementListItem   无 content、有 visible
  后台详情  AdminAnnouncement           有 content、有 visible

前端手抄的 Announcement / AnnouncementEdit / AnnouncementListItem 全部删掉,
AnnouncementEdit 改成从请求体派生(`CreateAnnouncementRequest & { id: number }`)。
后端 content.ts 的列表端点跟着换成 announcementListItemSchema —— 它本来就没
传 content,输出一字不变。

## SUBMISSION_RESULT 不再手抄

`-2 | -1 | 0 | ... | 10` 这 11 个码是从后端抄的,改成 `JudgeStatus | 9`:
后端那部分从契约派生,9 是前端本地的「正在提交」伪状态(后端永远不下发,
所以契约里没有,见 constants.ts 的 SubmissionStatus.submitting)。

这样 CLAUDE.md 说的「三处同步」才真的有人守:**实测过**,往
judgeStatusSchema 加一个 `z.literal(11)`,constants.ts 的 JUDGE_STATUS 立刻
报 TS2741 缺 '11' 的映射,加不上标签就编译不过。

顺带 useSubmissionMonitor.ts 里两处裸 `9`(各带一句 `// 9 = submitting`)
换成 SubmissionStatus.submitting,注释就不用写了。

## 顺手

TestcaseUploadedReturns 这个改名 re-export 去掉,直接用契约的
UploadTestCaseResponse(全仓 2 处引用)。

**没动 transforms.ts。** 之前把它记成「旧前端字段名的化石」,看下来判断有误:
filterResult 里 difficulty 要查 DIFFICULTY 映射表转中文、rate 要 getACRate 算、
status 要把 myStatus 翻成 passed/failed/not_test —— 是实打实的视图模型,不是
单纯改名。改它只会把计算逻辑挪个地方。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。因为动了后端响应 schema,起服务实跑了公告的四条路径:

- curl 三个端点逐个核对键集:oj 列表无 content/visible 且只出可见的那条、
  oj 详情有 content、后台列表有 visible 无 content 且两条都在。
- 浏览器里 oj 公告列表渲染正常、点开正文能出来;后台列表两行齐全;
  编辑页表单载入正确;改标题保存 PUT 200,库里 title 变了、visible/top 没被
  带歪,保存后跳回列表并重新拉取。

(agent-browser 的 `find text 保存 click` 打不到 naive-ui 的按钮 handler ——
不发请求也不报错,一开始误判成保存坏了。改用 DOM 上直接 .click() 就正常,
是自动化的坑,不是应用的问题。)

冒烟用的两条公告已删干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:54:01 -06:00

102 lines
3.1 KiB
TypeScript

import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { embeddedSubmissionSchema } from "./submission"
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
tag: z.string(),
content: z.string(),
top: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
})
/**
* 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题。
* 原来 content 写成 `.optional()` 让一个 schema 兼两种形态,结果详情页
* 拿到的 content 类型上也是 `string | undefined`,组件只能 ?? 兜底。
* 与后台侧 adminAnnouncementListItemSchema 同一个套路。
*/
export const announcementListItemSchema = announcementSchema.omit({
content: true,
})
export const announcementListSchema = paginatedSchema(
announcementListItemSchema,
)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: embeddedSubmissionSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z.string().min(1).max(1024 * 1024),
})
export const reactionKeySchema = z.enum([
"too_easy",
"too_hard",
"confusing",
"buggy",
"learned",
"interesting",
"want_explain",
])
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
export const reactionStateSchema = z.object({
mine: reactionKeySchema.nullable(),
counts: reactionCountsSchema.nullable(),
})
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
export const tutorialSummarySchema = z.object({
id: z.number().int(),
title: z.string(),
})
export const tutorialSchema = tutorialSummarySchema.extend({
content: z.string(),
code: z.string().nullable(),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>
export type Announcement = z.infer<typeof announcementSchema>
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
export type AnnouncementList = z.infer<typeof announcementListSchema>
export type CreateMessageRequest = z.infer<typeof createMessageRequestSchema>
export type ReactionKey = z.infer<typeof reactionKeySchema>
export type ReactionCounts = z.infer<typeof reactionCountsSchema>
export type ReactionState = z.infer<typeof reactionStateSchema>
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
export type Tutorial = z.infer<typeof tutorialSchema>
export type Exercise = z.infer<typeof exerciseSchema>