refactor(契约): 公告两侧形状分开,判题状态码从契约派生
Some checks failed
Deploy / deploy (push) Has been cancelled

## 公告:一个 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>
This commit is contained in:
2026-08-25 22:54:01 -06:00
parent 0b7b08f2cc
commit 5c772319e9
9 changed files with 74 additions and 45 deletions

View File

@@ -1,4 +1,5 @@
import { import {
announcementListItemSchema,
announcementListSchema, announcementListSchema,
announcementSchema, announcementSchema,
createMessageRequestSchema, createMessageRequestSchema,
@@ -35,7 +36,7 @@ contentRoutes.get("/announcements", async (c) => {
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset), .orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
]) ])
return success(c, announcementListSchema.parse({ return success(c, announcementListSchema.parse({
results: rows.map(({ announcement, user, realName }) => announcementSchema.parse({ results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({
id: announcement.id, id: announcement.id,
title: announcement.title, title: announcement.title,
tag: announcement.tag, tag: announcement.tag,

View File

@@ -2,7 +2,7 @@
import { NSwitch } from "naive-ui" import { NSwitch } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { AnnouncementListItem } from "utils/types" import type { AdminAnnouncementListItem } from "utils/types"
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api" import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
@@ -12,9 +12,9 @@ const query = reactive({
limit: 10, limit: 10,
page: 1, page: 1,
}) })
const announcements = ref<AnnouncementListItem[]>([]) const announcements = ref<AdminAnnouncementListItem[]>([])
const columns: DataTableColumn<AnnouncementListItem>[] = [ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
{ title: "ID", key: "id", width: 60 }, { title: "ID", key: "id", width: 60 },
{ title: "标题", key: "title", minWidth: 300 }, { title: "标题", key: "title", minWidth: 300 },
{ title: "标签", key: "tag", width: 80 }, { title: "标签", key: "tag", width: 80 },
@@ -64,7 +64,7 @@ const columns: DataTableColumn<AnnouncementListItem>[] = [
// 列表响应不含 content正文是 8MB 上限的富文本),而更新接口要求 content 必填 —— // 列表响应不含 content正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
// 拿列表里那行直接回传会 400。所以先取回整条再改。 // 拿列表里那行直接回传会 400。所以先取回整条再改。
async function toggleVisible(announcement: AnnouncementListItem) { async function toggleVisible(announcement: AdminAnnouncementListItem) {
const next = !announcement.visible const next = !announcement.visible
try { try {
const full = await getAnnouncement(announcement.id) const full = await getAnnouncement(announcement.id)

View File

@@ -21,16 +21,16 @@ import type {
AdminProblem, AdminProblem,
AdminProblemList, AdminProblemList,
AdminTag, AdminTag,
Announcement, AdminAnnouncement,
AnnouncementEdit, AnnouncementEdit,
AnnouncementListItem, AdminAnnouncementListItem,
BlankContest, BlankContest,
BlankProblem, BlankProblem,
Contest, Contest,
Exercise, Exercise,
ExerciseType, ExerciseType,
SqlDisplay, SqlDisplay,
TestcaseUploadedReturns, UploadTestCaseResponse,
Tutorial, Tutorial,
User, User,
WebsiteConfig, WebsiteConfig,
@@ -225,7 +225,7 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
if (options.sql) { if (options.sql) {
form.append("sql", "1") form.append("sql", "1")
} }
return api.post<TestcaseUploadedReturns>("admin/test-cases", form, { return api.post<UploadTestCaseResponse>("admin/test-cases", form, {
headers: { "content-type": "multipart/form-data" }, headers: { "content-type": "multipart/form-data" },
}) })
} }
@@ -371,14 +371,14 @@ export function deleteJudgeServer(hostname: string) {
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>( return api.get<{ results: AdminAnnouncementListItem[]; total: number }>(
"admin/announcements", "admin/announcements",
{ params: { offset, limit } }, { params: { offset, limit } },
) )
} }
export function getAnnouncement(id: number) { export function getAnnouncement(id: number) {
return api.get<Announcement>(`admin/announcements/${id}`) return api.get<AdminAnnouncement>(`admin/announcements/${id}`)
} }
export function deleteAnnouncement(id: number) { export function deleteAnnouncement(id: number) {
@@ -387,12 +387,12 @@ export function deleteAnnouncement(id: number) {
export function editAnnouncement(announcement: AnnouncementEdit) { export function editAnnouncement(announcement: AnnouncementEdit) {
const { id, ...body } = announcement const { id, ...body } = announcement
return api.put<Announcement>(`admin/announcements/${id}`, body) return api.put<AdminAnnouncement>(`admin/announcements/${id}`, body)
} }
export function createAnnouncement(announcement: AnnouncementEdit) { export function createAnnouncement(announcement: AnnouncementEdit) {
const { id: _id, ...body } = announcement const { id: _id, ...body } = announcement
return api.post<Announcement>("admin/announcements", body) return api.post<AdminAnnouncement>("admin/announcements", body)
} }
export function getTutorialList() { export function getTutorialList() {

View File

@@ -5,7 +5,7 @@ import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import type { Announcement } from "utils/types" import type { AnnouncementListItem } from "utils/types"
import TitleWithTag from "./components/TitleWithTag.vue" import TitleWithTag from "./components/TitleWithTag.vue"
const total = ref(0) const total = ref(0)
@@ -19,7 +19,7 @@ const query = reactive({
limit: 10, limit: 10,
page: 1, page: 1,
}) })
const columns: DataTableColumn<Announcement>[] = [ const columns: DataTableColumn<AnnouncementListItem>[] = [
{ {
key: "title", key: "title",
title: renderTableTitle("公告标题", "streamline-emojis:fire"), title: renderTableTitle("公告标题", "streamline-emojis:fire"),
@@ -45,20 +45,20 @@ const columns: DataTableColumn<Announcement>[] = [
width: 120, width: 120,
}, },
] ]
function rowProps(row: Announcement) { function rowProps(row: AnnouncementListItem) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onclick: () => showContent(row), onclick: () => showContent(row),
} }
} }
async function showContent(announcement: Announcement) { async function showContent(announcement: AnnouncementListItem) {
const res = await getAnnouncement(announcement.id) const res = await getAnnouncement(announcement.id)
toggleShow(true) toggleShow(true)
title.value = announcement.title title.value = announcement.title
content.value = res.content content.value = res.content
} }
const announcements = ref<Announcement[]>([]) const announcements = ref<AnnouncementListItem[]>([])
async function listAnnouncements() { async function listAnnouncements() {
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit

View File

@@ -41,6 +41,7 @@ import api from "utils/api"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Announcement, Announcement,
AnnouncementListItem,
ContestRank, ContestRank,
Profile, Profile,
Message, Message,
@@ -260,7 +261,7 @@ export function updateProfile(data: { realName: string; mood: string }) {
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: Announcement[]; total: number }>("announcements", { return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
params: { limit, offset }, params: { limit, offset },
}) })
} }

View File

@@ -106,7 +106,7 @@ export function useSubmissionMonitor() {
submission.value && submission.value &&
(submission.value.result === SubmissionStatus.judging || (submission.value.result === SubmissionStatus.judging ||
submission.value.result === SubmissionStatus.pending || submission.value.result === SubmissionStatus.pending ||
submission.value.result === 9) // 9 = submitting submission.value.result === SubmissionStatus.submitting)
) { ) {
console.log("[SubmissionMonitor] WebSocket未及时响应启动轮询保底") console.log("[SubmissionMonitor] WebSocket未及时响应启动轮询保底")
resumePolling() resumePolling()
@@ -119,7 +119,7 @@ export function useSubmissionMonitor() {
// ==================== 启动监控 ==================== // ==================== 启动监控 ====================
const startMonitoring = (id: string) => { const startMonitoring = (id: string) => {
submissionId.value = id submissionId.value = id
submission.value = { id, result: 9 } as Submission // 9 = submitting submission.value = { id, result: SubmissionStatus.submitting } as Submission
// 取消之前的断开计划 // 取消之前的断开计划
cancelScheduledDisconnect() cancelScheduledDisconnect()

View File

@@ -12,6 +12,8 @@ import type {
Grade, Grade,
ProblemDetail, ProblemDetail,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus,
CreateAnnouncementRequest,
} from "@oj2/contract" } from "@oj2/contract"
/** /**
@@ -64,8 +66,15 @@ export type {
SqlDisplayColumn, SqlDisplayColumn,
} from "@oj2/contract" } from "@oj2/contract"
export type SUBMISSION_RESULT = /**
-2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 * 判题状态码 + 前端本地的「正在提交」(9)。
*
* 后端那 11 个码**从契约派生**,不再手抄 —— judgeStatusSchema 加一个码,
* constants.ts 的 JUDGE_STATUS 会因为缺映射当场编译不过,这正是
* CLAUDE.md 说的「三处同步」想要的效果。9 是后端永远不会下发的伪状态,
* 见 constants.ts 的 SubmissionStatus.submitting。
*/
export type SUBMISSION_RESULT = JudgeStatus | 9
export type ProblemStatus = "passed" | "failed" | "not_test" export type ProblemStatus = "passed" | "failed" | "not_test"
@@ -89,7 +98,7 @@ export type {
* stripped_output_md5 / input_size / output_size。这套键名保持 snake_case * stripped_output_md5 / input_size / output_size。这套键名保持 snake_case
* 是因为它会原样落进 problem.test_case_score 和判题沙箱读的 info 文件。 * 是因为它会原样落进 problem.test_case_score 和判题沙箱读的 info 文件。
*/ */
export type { UploadTestCaseResponse as TestcaseUploadedReturns } from "@oj2/contract" export type { UploadTestCaseResponse } from "@oj2/contract"
/** /**
* 题目表单里的测试点:上传返回的条目 + 前端本地算出的分值。 * 题目表单里的测试点:上传返回的条目 + 前端本地算出的分值。
@@ -408,23 +417,21 @@ export type {
AcTrend, AcTrend,
} from "@oj2/contract" } from "@oj2/contract"
export interface AnnouncementEdit { /**
id: number * 公告。两侧形状**不同**原来这里只手抄了后台那份、oj 侧也拿它当类型用:
title: string * oj 的 `/announcements` 列表既不下发 `content` 也不下发 `visible`
tag: string * (见 apps/api/src/routes/content.ts于是类型声称有、实际是 undefined。
content: string * 现在各取各的契约类型。
visible: boolean */
top: boolean export type {
} Announcement,
AnnouncementListItem,
AdminAnnouncement,
AdminAnnouncementListItem,
} from "@oj2/contract"
export interface Announcement extends AnnouncementEdit { /** 后台编辑表单:请求体 + id新建时填 0提交前由 api 层剥掉) */
createdBy: SampleUser export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
createTime: string
lastUpdateTime: string
}
/** 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题 */
export type AnnouncementListItem = Omit<Announcement, "content">
/** /**
* 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个 * 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个
@@ -445,7 +452,7 @@ export type {
ReactionCounts, ReactionCounts,
ReactionState, ReactionState,
} from "@oj2/contract" } from "@oj2/contract"
import type { ReactionKey, SampleUser } from "@oj2/contract" import type { ReactionKey } from "@oj2/contract"
/** /**
* 教程。直接取契约 —— 手抄的那份把 `createdBy` 写成了可选的 `User`(后端下发的是 * 教程。直接取契约 —— 手抄的那份把 `createdBy` 写成了可选的 `User`(后端下发的是

View File

@@ -24,9 +24,13 @@ export const adminAnnouncementSchema = z.object({
lastUpdateTime: z.string(), lastUpdateTime: z.string(),
}) })
// 列表不带 content公告正文是 8MB 上限的富文本,列表页只显示标题
export const adminAnnouncementListItemSchema = adminAnnouncementSchema.omit({
content: true,
})
export const adminAnnouncementListSchema = paginatedSchema( export const adminAnnouncementListSchema = paginatedSchema(
// 列表不带 content公告正文是 8MB 上限的富文本,列表页只显示标题 adminAnnouncementListItemSchema,
adminAnnouncementSchema.omit({ content: true }),
) )
export const createAnnouncementRequestSchema = z.object({ export const createAnnouncementRequestSchema = z.object({
@@ -40,6 +44,9 @@ export const createAnnouncementRequestSchema = z.object({
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema> export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema>
export type AdminAnnouncementListItem = z.infer<
typeof adminAnnouncementListItemSchema
>
// ---------------------------------------------------------------- 教程 / 练习 // ---------------------------------------------------------------- 教程 / 练习

View File

@@ -7,14 +7,26 @@ export const announcementSchema = z.object({
id: z.number().int(), id: z.number().int(),
title: z.string(), title: z.string(),
tag: z.string(), tag: z.string(),
content: z.string().optional(), content: z.string(),
top: z.boolean(), top: z.boolean(),
createdBy: sampleUserSchema, createdBy: sampleUserSchema,
createTime: z.string(), createTime: z.string(),
lastUpdateTime: z.string(), lastUpdateTime: z.string(),
}) })
export const announcementListSchema = paginatedSchema(announcementSchema) /**
* 列表不下发正文:公告是 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({ export const messageSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -76,6 +88,7 @@ export const exerciseSchema = z.object({
export type Message = z.infer<typeof messageSchema> export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema> export type MessageList = z.infer<typeof messageListSchema>
export type Announcement = z.infer<typeof announcementSchema> export type Announcement = z.infer<typeof announcementSchema>
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
export type TutorialSummary = z.infer<typeof tutorialSummarySchema> export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
export type AnnouncementList = z.infer<typeof announcementListSchema> export type AnnouncementList = z.infer<typeof announcementListSchema>