From 5c772319e9a7e6e001c6ec59d397f53c2dddd08b Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 25 Aug 2026 22:54:01 -0600 Subject: [PATCH] =?UTF-8?q?refactor(=E5=A5=91=E7=BA=A6):=20=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E4=B8=A4=E4=BE=A7=E5=BD=A2=E7=8A=B6=E5=88=86=E5=BC=80?= =?UTF-8?q?=EF=BC=8C=E5=88=A4=E9=A2=98=E7=8A=B6=E6=80=81=E7=A0=81=E4=BB=8E?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=B4=BE=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 公告:一个 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 --- apps/api/src/routes/content.ts | 3 +- apps/web/src/admin/announcement/list.vue | 8 ++-- apps/web/src/admin/api.ts | 16 +++---- apps/web/src/oj/announcement/list.vue | 10 ++-- apps/web/src/oj/api.ts | 3 +- .../composables/useSubmissionMonitor.ts | 4 +- apps/web/src/utils/types.ts | 47 +++++++++++-------- packages/contract/src/admin.ts | 11 ++++- packages/contract/src/content.ts | 17 ++++++- 9 files changed, 74 insertions(+), 45 deletions(-) diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index 5a23a00..635a549 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -1,4 +1,5 @@ import { + announcementListItemSchema, announcementListSchema, announcementSchema, createMessageRequestSchema, @@ -35,7 +36,7 @@ contentRoutes.get("/announcements", async (c) => { .orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset), ]) return success(c, announcementListSchema.parse({ - results: rows.map(({ announcement, user, realName }) => announcementSchema.parse({ + results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({ id: announcement.id, title: announcement.title, tag: announcement.tag, diff --git a/apps/web/src/admin/announcement/list.vue b/apps/web/src/admin/announcement/list.vue index 04ee8db..1984a38 100644 --- a/apps/web/src/admin/announcement/list.vue +++ b/apps/web/src/admin/announcement/list.vue @@ -2,7 +2,7 @@ import { NSwitch } from "naive-ui" import Pagination from "shared/components/Pagination.vue" import { parseTime } from "utils/functions" -import type { AnnouncementListItem } from "utils/types" +import type { AdminAnnouncementListItem } from "utils/types" import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api" import Actions from "./components/Actions.vue" @@ -12,9 +12,9 @@ const query = reactive({ limit: 10, page: 1, }) -const announcements = ref([]) +const announcements = ref([]) -const columns: DataTableColumn[] = [ +const columns: DataTableColumn[] = [ { title: "ID", key: "id", width: 60 }, { title: "标题", key: "title", minWidth: 300 }, { title: "标签", key: "tag", width: 80 }, @@ -64,7 +64,7 @@ const columns: DataTableColumn[] = [ // 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 —— // 拿列表里那行直接回传会 400。所以先取回整条再改。 -async function toggleVisible(announcement: AnnouncementListItem) { +async function toggleVisible(announcement: AdminAnnouncementListItem) { const next = !announcement.visible try { const full = await getAnnouncement(announcement.id) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index b2e108a..42ee66f 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -21,16 +21,16 @@ import type { AdminProblem, AdminProblemList, AdminTag, - Announcement, + AdminAnnouncement, AnnouncementEdit, - AnnouncementListItem, + AdminAnnouncementListItem, BlankContest, BlankProblem, Contest, Exercise, ExerciseType, SqlDisplay, - TestcaseUploadedReturns, + UploadTestCaseResponse, Tutorial, User, WebsiteConfig, @@ -225,7 +225,7 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) { if (options.sql) { form.append("sql", "1") } - return api.post("admin/test-cases", form, { + return api.post("admin/test-cases", form, { headers: { "content-type": "multipart/form-data" }, }) } @@ -371,14 +371,14 @@ export function deleteJudgeServer(hostname: string) { } export function getAnnouncementList(offset = 0, limit = 10) { - return api.get<{ results: AnnouncementListItem[]; total: number }>( + return api.get<{ results: AdminAnnouncementListItem[]; total: number }>( "admin/announcements", { params: { offset, limit } }, ) } export function getAnnouncement(id: number) { - return api.get(`admin/announcements/${id}`) + return api.get(`admin/announcements/${id}`) } export function deleteAnnouncement(id: number) { @@ -387,12 +387,12 @@ export function deleteAnnouncement(id: number) { export function editAnnouncement(announcement: AnnouncementEdit) { const { id, ...body } = announcement - return api.put(`admin/announcements/${id}`, body) + return api.put(`admin/announcements/${id}`, body) } export function createAnnouncement(announcement: AnnouncementEdit) { const { id: _id, ...body } = announcement - return api.post("admin/announcements", body) + return api.post("admin/announcements", body) } export function getTutorialList() { diff --git a/apps/web/src/oj/announcement/list.vue b/apps/web/src/oj/announcement/list.vue index 027dd29..156ae22 100644 --- a/apps/web/src/oj/announcement/list.vue +++ b/apps/web/src/oj/announcement/list.vue @@ -5,7 +5,7 @@ import Pagination from "shared/components/Pagination.vue" import { useBreakpoints } from "shared/composables/breakpoints" import { parseTime } from "utils/functions" import { renderTableTitle } from "utils/renders" -import type { Announcement } from "utils/types" +import type { AnnouncementListItem } from "utils/types" import TitleWithTag from "./components/TitleWithTag.vue" const total = ref(0) @@ -19,7 +19,7 @@ const query = reactive({ limit: 10, page: 1, }) -const columns: DataTableColumn[] = [ +const columns: DataTableColumn[] = [ { key: "title", title: renderTableTitle("公告标题", "streamline-emojis:fire"), @@ -45,20 +45,20 @@ const columns: DataTableColumn[] = [ width: 120, }, ] -function rowProps(row: Announcement) { +function rowProps(row: AnnouncementListItem) { return { style: "cursor: pointer", onclick: () => showContent(row), } } -async function showContent(announcement: Announcement) { +async function showContent(announcement: AnnouncementListItem) { const res = await getAnnouncement(announcement.id) toggleShow(true) title.value = announcement.title content.value = res.content } -const announcements = ref([]) +const announcements = ref([]) async function listAnnouncements() { const offset = (query.page - 1) * query.limit diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 57a0039..9a9d003 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -41,6 +41,7 @@ import api from "utils/api" import { filterResult } from "oj/transforms" import type { Announcement, + AnnouncementListItem, ContestRank, Profile, Message, @@ -260,7 +261,7 @@ export function updateProfile(data: { realName: string; mood: string }) { } 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 }, }) } diff --git a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts index 3ff5632..ca523a6 100644 --- a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts +++ b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts @@ -106,7 +106,7 @@ export function useSubmissionMonitor() { submission.value && (submission.value.result === SubmissionStatus.judging || submission.value.result === SubmissionStatus.pending || - submission.value.result === 9) // 9 = submitting + submission.value.result === SubmissionStatus.submitting) ) { console.log("[SubmissionMonitor] WebSocket未及时响应,启动轮询保底") resumePolling() @@ -119,7 +119,7 @@ export function useSubmissionMonitor() { // ==================== 启动监控 ==================== const startMonitoring = (id: string) => { submissionId.value = id - submission.value = { id, result: 9 } as Submission // 9 = submitting + submission.value = { id, result: SubmissionStatus.submitting } as Submission // 取消之前的断开计划 cancelScheduledDisconnect() diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 725d2df..6685452 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -12,6 +12,8 @@ import type { Grade, ProblemDetail, ProblemDifficulty, + JudgeStatus, + CreateAnnouncementRequest, } from "@oj2/contract" /** @@ -64,8 +66,15 @@ export type { SqlDisplayColumn, } 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" @@ -89,7 +98,7 @@ export type { * (stripped_output_md5 / input_size / output_size)。这套键名保持 snake_case * 是因为它会原样落进 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, } from "@oj2/contract" -export interface AnnouncementEdit { - id: number - title: string - tag: string - content: string - visible: boolean - top: boolean -} +/** + * 公告。两侧形状**不同**,原来这里只手抄了后台那份、oj 侧也拿它当类型用: + * oj 的 `/announcements` 列表既不下发 `content` 也不下发 `visible` + * (见 apps/api/src/routes/content.ts),于是类型声称有、实际是 undefined。 + * 现在各取各的契约类型。 + */ +export type { + Announcement, + AnnouncementListItem, + AdminAnnouncement, + AdminAnnouncementListItem, +} from "@oj2/contract" -export interface Announcement extends AnnouncementEdit { - createdBy: SampleUser - createTime: string - lastUpdateTime: string -} - -/** 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题 */ -export type AnnouncementListItem = Omit +/** 后台编辑表单:请求体 + id(新建时填 0,提交前由 api 层剥掉) */ +export type AnnouncementEdit = CreateAnnouncementRequest & { id: number } /** * 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个 @@ -445,7 +452,7 @@ export type { ReactionCounts, ReactionState, } from "@oj2/contract" -import type { ReactionKey, SampleUser } from "@oj2/contract" +import type { ReactionKey } from "@oj2/contract" /** * 教程。直接取契约 —— 手抄的那份把 `createdBy` 写成了可选的 `User`(后端下发的是 diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 060f8f3..3a9a105 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -24,9 +24,13 @@ export const adminAnnouncementSchema = z.object({ lastUpdateTime: z.string(), }) +// 列表不带 content:公告正文是 8MB 上限的富文本,列表页只显示标题 +export const adminAnnouncementListItemSchema = adminAnnouncementSchema.omit({ + content: true, +}) + export const adminAnnouncementListSchema = paginatedSchema( - // 列表不带 content:公告正文是 8MB 上限的富文本,列表页只显示标题 - adminAnnouncementSchema.omit({ content: true }), + adminAnnouncementListItemSchema, ) export const createAnnouncementRequestSchema = z.object({ @@ -40,6 +44,9 @@ export const createAnnouncementRequestSchema = z.object({ export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema export type AdminAnnouncement = z.infer +export type AdminAnnouncementListItem = z.infer< + typeof adminAnnouncementListItemSchema +> // ---------------------------------------------------------------- 教程 / 练习 diff --git a/packages/contract/src/content.ts b/packages/contract/src/content.ts index 899803f..8cb098c 100644 --- a/packages/contract/src/content.ts +++ b/packages/contract/src/content.ts @@ -7,14 +7,26 @@ export const announcementSchema = z.object({ id: z.number().int(), title: z.string(), tag: z.string(), - content: z.string().optional(), + content: z.string(), top: z.boolean(), createdBy: sampleUserSchema, createTime: 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({ id: z.number().int(), @@ -76,6 +88,7 @@ export const exerciseSchema = z.object({ export type Message = z.infer export type MessageList = z.infer export type Announcement = z.infer +export type AnnouncementListItem = z.infer export type TutorialSummary = z.infer export type AnnouncementList = z.infer