refactor(契约): 补上 AI / 站内信 / 自学留痕等接口的闸门,并订正前端 CLAUDE.md 的过期内容
Some checks failed
Deploy / deploy (push) Has been cancelled

闸门再补 13 个端点:AI 系列(detail / solved / duration / heatmap /
login-summary / pinned)、站内信列表、教程列表与学习进度、题目逐年 AC、
流程图列表。学生端读接口至此基本覆盖(写操作与纯前端伪状态不接)。

文档订正(原文写着"后端是 ../OnlineJudge 的 Django 5"、"utils/http.ts"、
"utils/permissions.ts",这些都早已不存在):
- 项目概述改成 OJ2 前端,并点明要兼容机房老 Chrome;
- HTTP 客户端改成实际存在的 utils/api.ts;
- 新增「Contract guard」一节,把**失败策略**(记日志 + 放行原始数据、不抛错)
  和排查入口(window.__OJ2_CONTRACT_DRIFT__)写清楚,并提醒同一个 schema
  后端也在 parse、收紧前要用生产数据核验;
- Related Repository 指向同仓的 ../api 与 packages/contract。

验证:vue-tsc 与 vite build 通过;13 个新端点对真实接口全部通过
(11 通过 / 0 失败 / 0 跳过)。
This commit is contained in:
2026-09-10 04:25:24 -06:00
parent de34a47996
commit 5da661d7f0
2 changed files with 123 additions and 47 deletions

View File

@@ -4,7 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**ojnext** is the frontend for an Online Judge platform. Built with Vue 3 + TypeScript using Vite (Rolldown-based bundler), Naive UI component library, Pinia for state management, and Vue Router. **OJ2 的前端**`OJ2/apps/web`),代码从上一代 `ojnext/` 原样搬来、只替换了 API 层
`ojnext``../OnlineJudge` 都已下线且**完全冻结,一行都不改**。Vue 3 + TypeScript
ViteRolldown 内核、Naive UI、Pinia、Vue Router。
**要兼容机房的老 Chrome< 94**`vite.config.ts` 的 legacy 配置与
`mermaid-legacy` 等 fallback 依赖不能动,理由写在该文件的注释里。
## Commands ## Commands
@@ -70,15 +75,37 @@ shared → ./src/shared
### HTTP Client ### HTTP Client
`utils/http.ts` — Axios instance with interceptors. All API calls proxy through the dev server: `utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`,
- `/api` and `/public``PUBLIC_OJ_URL` (backend) `withCredentials`). It unwraps both the axios envelope and the backend's
- `/ws``PUBLIC_WS_URL` (WebSocket backend) `{ data }` envelope, so callers get the payload directly. All API calls proxy
through the dev server (see `vite.config.ts`).
### Contract guard (`utils/contract.ts`)
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**`utils/types.ts` 只做
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫:
```ts
const endpoint = `problems/${encodeURIComponent(id)}`
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint))
```
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后,
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。
改动 schema 时要记住**同一个 schema 后端也在 `parse`**(如
`submissionDetailSchema.parse` 在路由里),所以收紧一个字段前先用生产数据核一遍,
否则一条不符合的历史记录会让整个列表 500。
### Key Utilities ### Key Utilities
- `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types - `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types
- `utils/types.ts`TypeScript interfaces for all domain models - `utils/types.ts`契约类型的派生与前端专有收窄(不是手写的一份平行类型)
- `utils/permissions.ts` — Permission check helpers - `utils/contract.ts` — 运行时契约闸门,见上
- `utils/judge.ts` — Judge-related utilities - `utils/judge.ts` — Judge-related utilities
- `utils/renders.ts` — Table column render helpers for Naive UI DataTable - `utils/renders.ts` — Table column render helpers for Naive UI DataTable
@@ -113,4 +140,6 @@ Routes are defined in `src/routes.ts` with two root routes: `ojs` (user-facing)
## Related Repository ## Related Repository
The backend is at `../OnlineJudge` — a Django 5 + DRF project. See its CLAUDE.md for backend details. 后端就在同一个仓库的 `../api`Bun + Hono + Drizzle编译成单二进制
契约在 `../../packages/contract`。**不要再去看 `OnlineJudge/`** —— 那是已下线的
Django 后端,只作参照、完全冻结。详见 `../CLAUDE.md`

View File

@@ -1,20 +1,12 @@
import { import {
type AiAnalysisRecord,
type FormatCodeResponse, type FormatCodeResponse,
type TutorialSummary,
type ClassComparisonResponse, type ClassComparisonResponse,
type ClassRankItem, type ClassRankItem,
type ClassUserRank, type ClassUserRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemAuthor, type ProblemAuthor,
type YearlyAc,
type CreateFlowchartResponse, type CreateFlowchartResponse,
type FlowchartSubmission, type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type SolvedList,
problemDetailSchema, problemDetailSchema,
problemListSchema, problemListSchema,
problemListItemSchema, problemListItemSchema,
@@ -45,20 +37,28 @@ import {
flowchartStatisticsSchema, flowchartStatisticsSchema,
problemSetProgressListSchema, problemSetProgressListSchema,
problemSetProblemSchema, problemSetProblemSchema,
tutorialSummarySchema,
tutorialProgressSchema,
messageListSchema,
yearlyAcSchema,
aiDetailSchema,
solvedListSchema,
durationDataSchema,
heatmapItemSchema,
loginSummarySchema,
aiAnalysisRecordSchema,
} from "@oj2/contract" } from "@oj2/contract"
import api from "utils/api" import api from "utils/api"
import { contract } from "utils/contract" import { contract } from "utils/contract"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Profile, Profile,
Message,
Exercise, Exercise,
Problem, Problem,
ReactionKey, ReactionKey,
ReactionState, ReactionState,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
TutorialProgress,
} from "utils/types" } from "utils/types"
/** /**
@@ -384,11 +384,13 @@ export async function getAnnouncement(id: number) {
) )
} }
export function getMessageList(offset = 0, limit = 10) { export async function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions见那里的说明 const endpoint = "messages"
return api.get<{ results: Message[]; total: number }>("messages", { return contract(
params: { limit, offset }, "GET /messages",
}) messageListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
} }
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
@@ -417,48 +419,85 @@ export async function getTutorial(id: number) {
) )
} }
export function getTutorials(type: "python" | "c") { export async function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } }) const endpoint = "tutorials"
return contract(
"GET /tutorials",
tutorialSummarySchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
} }
export function getAIDetailData(start: string, end: string, username?: string) { export async function getAIDetailData(
return api.get<AiDetail>("ai/detail", { params: { start, end, username } }) start: string,
end: string,
username?: string,
) {
const endpoint = "ai/detail"
return contract(
"GET /ai/detail",
aiDetailSchema,
await api.get<unknown>(endpoint, { params: { start, end, username } }),
)
} }
export function getAISolved( export async function getAISolved(
start: string, start: string,
end: string, end: string,
offset: number, offset: number,
limit: number, limit: number,
username?: string, username?: string,
) { ) {
return api.get<SolvedList>("ai/solved", { const endpoint = "ai/solved"
params: { start, end, offset, limit, username }, return contract(
}) "GET /ai/solved",
solvedListSchema,
await api.get<unknown>(endpoint, {
params: { start, end, offset, limit, username },
}),
)
} }
export function getAIDurationData( export async function getAIDurationData(
end: string, end: string,
duration: string, duration: string,
username?: string, username?: string,
) { ) {
return api.get<DurationData[]>("ai/duration", { const endpoint = "ai/duration"
params: { end, duration, username }, return contract(
}) "GET /ai/duration",
durationDataSchema.array(),
await api.get<unknown>(endpoint, { params: { end, duration, username } }),
)
} }
export function getAIHeatmapData(username?: string) { export async function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", { const endpoint = "ai/heatmap"
params: username ? { username } : {}, return contract(
}) "GET /ai/heatmap",
heatmapItemSchema.array(),
await api.get<unknown>(endpoint, {
params: username ? { username } : {},
}),
)
} }
export function getAILoginSummary() { export async function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary") const endpoint = "ai/login-summary"
return contract(
"GET /ai/login-summary",
loginSummarySchema,
await api.get<unknown>(endpoint),
)
} }
export function getAIPinnedReport() { export async function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned") const endpoint = "ai/pinned"
return contract(
"GET /ai/pinned",
aiAnalysisRecordSchema.nullable(),
await api.get<unknown>(endpoint),
)
} }
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
@@ -475,9 +514,12 @@ export async function getSimilarProblems(problemId: string) {
export type { YearlyAc as YearlyACData } from "@oj2/contract" export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) { export async function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>( const endpoint = `problems/${encodeURIComponent(problemId)}/yearly-ac`
`problems/${encodeURIComponent(problemId)}/yearly-ac`, return contract(
"GET /problems/:id/yearly-ac",
yearlyAcSchema.array(),
await api.get<unknown>(endpoint),
) )
} }
@@ -666,8 +708,13 @@ export function reportExerciseAttempt(
}).catch(() => undefined) }).catch(() => undefined)
} }
export function getLearnProgress(type: "python" | "c") { export async function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } }) const endpoint = "learn/progress"
return contract(
"GET /learn/progress",
tutorialProgressSchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
} }
/** /**