From 8a88fe8d998fabe43d592795f3e503720cd5fbd4 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 3 Sep 2026 03:18:18 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=99=BA=E8=83=BD=E5=88=86=E6=9E=90):=20?= =?UTF-8?q?=E8=A7=A3=E9=A2=98=E8=A1=A8=E6=A0=BC=E6=94=B9=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E5=88=86=E9=A1=B5=EF=BC=8C/ai/detail=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=B8=8B=E5=8F=91=E6=95=B4=E4=BB=BD=20solved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来 /ai/detail 把区间内做出来的每道题连排名带等级整份下发,一个活跃学生一年几百道, 一次请求就是几百 KB,而表格一屏只看得到二十行。 拆成两支: - GET /ai/detail 只留聚合(solvedCount、difficulty、tags、attempts、activity、errors…) - GET /ai/solved?offset&limit 按首次通过时间升序分页给逐题明细 排名只跟当前这批题有关,所以分页那支只算一页的 problemIds,不必把整年算一遍。抽出 firstAcQuery / buildSolved / listSolved 三个函数,detail 和分页两边共用。 前端相应改动: - 难度分布改读后端的 difficulty 聚合(本来就在下发,之前是从逐题列表里现数的) - 几次做对改读新的 attempts 数组(每道题的尝试次数);tooltip 里的题名去掉了 —— 明细是分页拿的,不该为了一个 tooltip 把全量拉回来 - Overview 用 solvedCount - SolvedTable 的代码提交那张走 remote 分页,流程图那张仍是本地分页(一个 OJ 的 流程图题就那么几道,全量下发没问题);换时间范围回到第一页 - 表格原来是 max-height 1500 的滚动区,现在由分页兜住,滚动条和分页器不再并存 /ai/analysis 的 prompt 仍然带逐题明细(少了它模型只剩聚合数字),但顺手卡了 200 条 —— 以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn --- apps/api/src/routes/ai.ts | 156 ++++++++++++------ .../src/oj/ai/components/AttemptsChart.vue | 27 ++- .../oj/ai/components/DifficultyGradeChart.vue | 11 +- apps/web/src/oj/ai/components/Overview.vue | 4 +- apps/web/src/oj/ai/components/SolvedTable.vue | 44 +++-- apps/web/src/oj/api.ts | 13 ++ apps/web/src/oj/store/ai.ts | 43 ++++- apps/web/src/utils/types.ts | 1 + packages/contract/src/ai.ts | 15 +- 9 files changed, 225 insertions(+), 89 deletions(-) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 6592079..88bdc3e 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -8,9 +8,10 @@ import { durationDataSchema, heatmapItemSchema, loginSummarySchema, + solvedListSchema, solvedProblemSchema, } from "@oj2/contract" -import { and, count, countDistinct, eq, gte, inArray, isNull, lte, min, notInArray, sql } from "drizzle-orm" +import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, notInArray, sql } from "drizzle-orm" import { Hono, type Context } from "hono" import { requireAuth, type AppEnv } from "../auth/middleware" @@ -21,7 +22,7 @@ import { JudgeStatus } from "../judge/status" import { failure, success } from "../http" import { completeChat, streamChat } from "../services/ai" import { consumeToken } from "../services/throttling" -import { isTeacherOrAbove, objectValue, rounded } from "./helpers" +import { isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers" export const aiRoutes = new Hono() @@ -93,6 +94,85 @@ async function targetUser(c: Context, override?: string) { return target ?? null } +type FirstAcRow = { problemId: number; first: string | null } + +/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */ +function firstAcQuery(user: AuthUser, start: string, end: string, limit?: number, offset?: number) { + const first = min(schema.submission.createTime) + const query = db.select({ problemId: schema.submission.problemId, first }) + .from(schema.submission).where(and( + eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), + gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), + )).groupBy(schema.submission.problemId).orderBy(asc(first)) + return limit === undefined ? query : query.limit(limit).offset(offset ?? 0) +} + +/** + * 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。 + * 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。 + */ +async function buildSolved(user: AuthUser, start: string, end: string, firstAc: FirstAcRow[]) { + const problemIds = firstAc.map((item) => item.problemId) + if (!problemIds.length) return { solved: [], problems: [] as { problem: typeof schema.problem.$inferSelect; contestTitle: string | null }[], scopeIds: null as number[] | null } + const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : [] + const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null + const [problems, rankRows, periodRows, attemptRows] = await Promise.all([ + db.select({ problem: schema.problem, contestTitle: schema.contest.title }).from(schema.problem) + .leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id)).where(inArray(schema.problem.id, problemIds)), + db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) }) + .from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined)) + .groupBy(schema.submission.userId, schema.submission.problemId), + db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) }) + .from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined)) + .groupBy(schema.submission.userId, schema.submission.problemId), + db.select({ problemId: schema.submission.problemId, time: schema.submission.createTime }) + .from(schema.submission).where(and( + eq(schema.submission.userId, user.id), inArray(schema.submission.problemId, problemIds), + gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), + )), + ]) + const byProblem = new Map(problems.map((item) => [item.problem.id, item])) + // 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交 + const firstAcTime = new Map(firstAc.flatMap((item) => (item.first ? [[item.problemId, Date.parse(item.first)]] as const : []))) + const attemptsByProblem = new Map() + for (const row of attemptRows) { + const deadline = firstAcTime.get(row.problemId) + if (deadline === undefined || Date.parse(row.time) > deadline) continue + attemptsByProblem.set(row.problemId, (attemptsByProblem.get(row.problemId) ?? 0) + 1) + } + function ranks(rows: typeof rankRows, problemId: number) { + return rows.filter((item) => item.problemId === problemId).sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId) + } + const solved = firstAc.flatMap((item) => { + const problem = byProblem.get(item.problemId) + if (!problem || !item.first) return [] + const all = ranks(rankRows, item.problemId) + const period = ranks(periodRows, item.problemId) + const rank = all.findIndex((row) => row.userId === user.id) + 1 || null + const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null + return solvedProblemSchema.parse({ + problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId }, + acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length, + difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", + attempts: attemptsByProblem.get(item.problemId) ?? 1, + }) + }).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) + return { solved, problems, scopeIds } +} + +/** 分页版:只算这一页的题 */ +async function listSolved(user: AuthUser, start: string, end: string, limit: number, offset: number) { + const [firstAc, totalRows] = await Promise.all([ + firstAcQuery(user, start, end, limit, offset), + db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and( + eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), + gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), + )), + ]) + const { solved } = await buildSolved(user, start, end, firstAc) + return solvedListSchema.parse({ results: solved, total: totalRows[0]?.value ?? 0 }) +} + async function buildDetail(user: AuthUser, start: string, end: string) { // 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次 // 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。 @@ -126,59 +206,20 @@ async function buildDetail(user: AuthUser, start: string, end: string) { const errors = [...errorCounts] .map(([result, count]) => ({ result, count })) .sort((a, b) => b.count - a.count || a.result - b.result) - const firstAc = await db.select({ problemId: schema.submission.problemId, first: min(schema.submission.createTime) }) - .from(schema.submission).where(and( - eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), - gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), - )).groupBy(schema.submission.problemId) + const firstAc = await firstAcQuery(user, start, end) const problemIds = firstAc.map((item) => item.problemId) if (!problemIds.length) return aiDetailSchema.parse({ - user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0, + user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0, activity, errors, rankScope: "global", }) - const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : [] - const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null - const [problems, rankRows, periodRows, tagRows, flowRows] = await Promise.all([ - db.select({ problem: schema.problem, contestTitle: schema.contest.title }).from(schema.problem) - .leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id)).where(inArray(schema.problem.id, problemIds)), - db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) }) - .from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined)) - .groupBy(schema.submission.userId, schema.submission.problemId), - db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) }) - .from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined)) - .groupBy(schema.submission.userId, schema.submission.problemId), + const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([ + buildSolved(user, start, end, firstAc), db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags) .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)), db.select({ flow: schema.flowchartSubmission, displayId: schema.problem.displayId, title: schema.problem.title }) .from(schema.flowchartSubmission).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where(and(eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.status, 2), gte(schema.flowchartSubmission.createTime, start), lte(schema.flowchartSubmission.createTime, end))), ]) - const byProblem = new Map(problems.map((item) => [item.problem.id, item])) - // 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交 - const firstAcTime = new Map(firstAc.flatMap((item) => (item.first ? [[item.problemId, Date.parse(item.first)]] as const : []))) - const attemptsByProblem = new Map() - for (const row of submissions) { - const deadline = firstAcTime.get(row.problemId) - if (deadline === undefined || Date.parse(row.time) > deadline) continue - attemptsByProblem.set(row.problemId, (attemptsByProblem.get(row.problemId) ?? 0) + 1) - } - function ranks(rows: typeof rankRows, problemId: number) { - return rows.filter((item) => item.problemId === problemId).sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId) - } - const solved = firstAc.flatMap((item) => { - const problem = byProblem.get(item.problemId) - if (!problem || !item.first) return [] - const all = ranks(rankRows, item.problemId) - const period = ranks(periodRows, item.problemId) - const rank = all.findIndex((row) => row.userId === user.id) + 1 || null - const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null - return solvedProblemSchema.parse({ - problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId }, - acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length, - difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", - attempts: attemptsByProblem.get(item.problemId) ?? 1, - }) - }).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) const tags: Record = {} for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1 const topTags = Object.fromEntries(Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 5)) @@ -205,7 +246,8 @@ async function buildDetail(user: AuthUser, start: string, end: string) { } }).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime)) return aiDetailSchema.parse({ - user: user.username, className: user.className, start, end, solved, flowcharts, + user: user.username, className: user.className, start, end, flowcharts, + solvedCount: solved.length, attempts: solved.map((item) => item.attempts), grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty, contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size, activity, errors, rankScope: scopeIds ? "class" : "global", @@ -223,6 +265,19 @@ aiRoutes.get("/ai/detail", requireAuth, async (c) => { return success(c, await buildDetail(user, start, end)) }) +aiRoutes.get("/ai/solved", requireAuth, async (c) => { + const start = c.req.query("start") + const end = c.req.query("end") + if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { + return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps") + } + const user = await targetUser(c) + if (!user) return failure(c, 404, "user-not-found", "User not found") + const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 100 }) + const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) + return success(c, await listSolved(user, start, end, limit, offset)) +}) + function shiftMonths(date: Date, months: number) { const result = new Date(date) const day = result.getDate() @@ -414,17 +469,20 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => { const limited = await throttleAi(c) if (limited) return limited // 学情数据一律服务端重算,客户端只说看谁、哪段时间 - const [details, duration] = await Promise.all([ + // detail 现在只带聚合,逐题明细单独取一页给模型看。顺带把喂进 prompt 的条数 + // 卡在 200 —— 以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token + const [details, duration, solved] = await Promise.all([ buildDetail(user, parsed.data.start, parsed.data.end), buildDuration(user, parsed.data.end, parsed.data.duration), + listSolved(user, parsed.data.start, parsed.data.end, 200, 0), ]) const system = "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。" - const prompt = `详细数据: ${JSON.stringify(details)}\n每周或每月数据: ${JSON.stringify(duration)}` + const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}` return streamChat(system, prompt, async (analysis) => { // 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的 // GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到 await db.insert(schema.aiAnalysis).values({ - provider: config.aiProvider, model: config.aiModel, data: { details, duration }, systemPrompt: system, + provider: config.aiProvider, model: config.aiModel, data: { details, duration, solved: solved.results }, systemPrompt: system, userPrompt: "学习详情与周期数据", analysis, createTime: new Date().toISOString(), userId: user.id, isPinned: false, }) }) diff --git a/apps/web/src/oj/ai/components/AttemptsChart.vue b/apps/web/src/oj/ai/components/AttemptsChart.vue index 277aecb..55bbef3 100644 --- a/apps/web/src/oj/ai/components/AttemptsChart.vue +++ b/apps/web/src/oj/ai/components/AttemptsChart.vue @@ -28,8 +28,8 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip) const aiStore = useAIStore() const { chartKey } = useChartTheme() -// solved[].attempts 是后端算的「到首次通过为止的提交次数」,分档放在前端, -// 想调档位不用改契约 +// detailsData.attempts 是后端算的「每道题到首次通过为止的提交次数」,分档放在前端, +// 想调档位不用改契约。逐题的题名不在这里 —— 明细是分页拿的,别为了 tooltip 把全量拉回来 const BUCKETS = [ { label: "一次过", color: "#18A058", min: 1, max: 1 }, { label: "2-3 次", color: "#2080F0", min: 2, max: 3 }, @@ -40,20 +40,20 @@ const BUCKETS = [ const buckets = computed(() => BUCKETS.map((bucket) => ({ ...bucket, - problems: aiStore.detailsData.solved.filter( - (item) => item.attempts >= bucket.min && item.attempts <= bucket.max, - ), + count: aiStore.detailsData.attempts.filter( + (value) => value >= bucket.min && value <= bucket.max, + ).length, })), ) -const show = computed(() => aiStore.detailsData.solved.length > 0) +const show = computed(() => aiStore.detailsData.attempts.length > 0) const data = computed(() => ({ labels: buckets.value.map((bucket) => bucket.label), datasets: [ { label: "题目数量", - data: buckets.value.map((bucket) => bucket.problems.length), + data: buckets.value.map((bucket) => bucket.count), backgroundColor: buckets.value.map((bucket) => bucket.color), borderColor: buckets.value.map((bucket) => bucket.color), borderWidth: 1, @@ -78,14 +78,11 @@ const options = computed>(() => ({ legend: { display: false }, tooltip: { callbacks: { - label: (ctx) => `${ctx.parsed.y} 道题`, - afterLabel: (ctx) => { - const titles = buckets.value[ctx.dataIndex]!.problems.map( - (item) => `${item.problem.displayId} ${item.problem.title}`, - ) - if (!titles.length) return "" - if (titles.length <= 5) return titles - return [...titles.slice(0, 4), `… 还有 ${titles.length - 4} 道`] + label: (ctx) => { + const value = Number(ctx.parsed.y) + const total = aiStore.detailsData.attempts.length + const percent = total ? (value / total) * 100 : 0 + return `${value} 道题(占 ${percent.toFixed(0)}%)` }, }, }, diff --git a/apps/web/src/oj/ai/components/DifficultyGradeChart.vue b/apps/web/src/oj/ai/components/DifficultyGradeChart.vue index 7bc4b4b..236ecd2 100644 --- a/apps/web/src/oj/ai/components/DifficultyGradeChart.vue +++ b/apps/web/src/oj/ai/components/DifficultyGradeChart.vue @@ -34,16 +34,13 @@ const difficultyOrder = ["简单", "中等", "困难"] const difficultyColors = ["#18A058", "#F0A020", "#D03050"] // 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子, -// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有 +// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有。 +// 直接读后端的 difficulty 聚合 —— 逐题列表现在是分页拿的,前端手上没有全量 const counts = computed(() => - difficultyOrder.map( - (name) => - aiStore.detailsData.solved.filter((item) => item.difficulty === name) - .length, - ), + difficultyOrder.map((name) => aiStore.detailsData.difficulty[name] ?? 0), ) -const show = computed(() => aiStore.detailsData.solved.length > 0) +const show = computed(() => aiStore.detailsData.solvedCount > 0) const data = computed(() => ({ labels: difficultyOrder, diff --git a/apps/web/src/oj/ai/components/Overview.vue b/apps/web/src/oj/ai/components/Overview.vue index 13ca626..62d0d84 100644 --- a/apps/web/src/oj/ai/components/Overview.vue +++ b/apps/web/src/oj/ai/components/Overview.vue @@ -2,11 +2,11 @@ {{ durationLabel }}, 你一共解决 - {{ aiStore.detailsData.solved.length }} + {{ aiStore.detailsData.solvedCount }} 道题 ,并且参加 diff --git a/apps/web/src/oj/ai/components/SolvedTable.vue b/apps/web/src/oj/ai/components/SolvedTable.vue index 44ad4df..4d23402 100644 --- a/apps/web/src/oj/ai/components/SolvedTable.vue +++ b/apps/web/src/oj/ai/components/SolvedTable.vue @@ -1,11 +1,14 @@ @@ -38,16 +44,30 @@ import { NButton, NTooltip } from "naive-ui" import TagTitle from "./TagTitle.vue" import type { FlowchartSummary, SolvedProblem } from "utils/types" import { useAIStore } from "oj/store/ai" -import { useBreakpoints } from "shared/composables/breakpoints" import { parseTime } from "utils/functions" const router = useRouter() const aiStore = useAIStore() -const { isDesktop } = useBreakpoints() - -const submissions = computed(() => aiStore.detailsData.solved) +const hasSolved = computed(() => aiStore.detailsData.solvedCount > 0) const flowcharts = computed(() => aiStore.detailsData.flowcharts) + +// 代码提交这张走服务端分页:翻页只拉一页,不把整年的题一次发给浏览器。 +// 流程图那张仍然是全量下发的(一个 OJ 的流程图题就那么几道),本地分页即可。 +// 行数不够一页时不显示分页器,和 ExerciseAttempts.vue 一致 +const solvedPagination = computed(() => + aiStore.solvedTotal > aiStore.solvedPageSize + ? { + page: aiStore.solvedPage, + pageSize: aiStore.solvedPageSize, + itemCount: aiStore.solvedTotal, + } + : false, +) +function paginationFor(rows: unknown[]) { + const pageSize = aiStore.solvedPageSize + return rows.length > pageSize ? { pageSize } : false +} const columns: DataTableColumn[] = [ { title: "完成的题目", diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 55e0102..15b5957 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -26,6 +26,7 @@ import { type DurationData, type HeatmapItem, type LoginSummary, + type SolvedList, type ProblemSet, type ProblemSetBadge, type ProblemSetList, @@ -302,6 +303,18 @@ export function getAIDetailData(start: string, end: string, username?: string) { return api.get("ai/detail", { params: { start, end, username } }) } +export function getAISolved( + start: string, + end: string, + offset: number, + limit: number, + username?: string, +) { + return api.get("ai/solved", { + params: { start, end, offset, limit, username }, + }) +} + export function getAIDurationData( end: string, duration: string, diff --git a/apps/web/src/oj/store/ai.ts b/apps/web/src/oj/store/ai.ts index bd74a58..9a5dd97 100644 --- a/apps/web/src/oj/store/ai.ts +++ b/apps/web/src/oj/store/ai.ts @@ -1,10 +1,11 @@ -import type { DetailsData, DurationData } from "utils/types" +import type { DetailsData, DurationData, SolvedProblem } from "utils/types" import { aiStreamError, consumeJSONEventStream } from "utils/stream" import { getAIDetailData, getAIDurationData, getAIHeatmapData, getAIPinnedReport, + getAISolved, } from "../api" export const useAIStore = defineStore("ai", () => { @@ -23,7 +24,8 @@ export const useAIStore = defineStore("ai", () => { tags: {}, difficulty: {}, contestCount: 0, - solved: [], + solvedCount: 0, + attempts: [], flowcharts: [], activity: [], errors: [], @@ -31,10 +33,17 @@ export const useAIStore = defineStore("ai", () => { }) const heatmapData = ref<{ timestamp: number; value: number }[]>([]) + // 解题明细走服务端分页:一个活跃学生一年几百道题,整份跟着 detail 一起下发没必要 + const solvedRows = ref([]) + const solvedTotal = ref(0) + const solvedPage = ref(1) + const solvedPageSize = ref(20) + const loading = reactive({ fetching: false, // 合并 details 和 duration 的 loading ai: false, heatmap: false, + solved: false, }) const mdContent = ref("") @@ -48,12 +57,13 @@ export const useAIStore = defineStore("ai", () => { ) detailsData.start = res.start detailsData.end = res.end - detailsData.solved = res.solved detailsData.grade = res.grade detailsData.className = res.className detailsData.tags = res.tags detailsData.difficulty = res.difficulty detailsData.contestCount = res.contestCount + detailsData.solvedCount = res.solvedCount + detailsData.attempts = res.attempts detailsData.activity = res.activity detailsData.errors = res.errors detailsData.rankScope = res.rankScope @@ -69,6 +79,25 @@ export const useAIStore = defineStore("ai", () => { durationData.value = res } + async function fetchSolved(page = solvedPage.value) { + if (!rangeStart.value || !rangeEnd.value) return + loading.solved = true + try { + const res = await getAISolved( + rangeStart.value, + rangeEnd.value, + (page - 1) * solvedPageSize.value, + solvedPageSize.value, + targetUsername.value || undefined, + ) + solvedRows.value = res.results + solvedTotal.value = res.total + solvedPage.value = page + } finally { + loading.solved = false + } + } + async function fetchHeatmapData() { loading.heatmap = true const res = await getAIHeatmapData(targetUsername.value || undefined) @@ -83,11 +112,14 @@ export const useAIStore = defineStore("ai", () => { ) { rangeStart.value = start rangeEnd.value = end + // 换时间范围就回到第一页,否则停在第 5 页但新范围只有两页 + solvedPage.value = 1 loading.fetching = true try { await Promise.all([ fetchDetailsData(start, end), fetchDurationData(end, duration), + fetchSolved(1), ]) } finally { loading.fetching = false @@ -196,6 +228,11 @@ export const useAIStore = defineStore("ai", () => { return { fetchAnalysisData, fetchHeatmapData, + fetchSolved, + solvedRows, + solvedTotal, + solvedPage, + solvedPageSize, fetchAIAnalysis, fetchPinnedReport, simulatePinnedStream, diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 4da0b26..7b3e2ed 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -555,6 +555,7 @@ export type { DurationData, FlowchartSummary, SolvedProblem, + SolvedList, AiDetail as DetailsData, } from "@oj2/contract" diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts index 908e0e6..e6e5c7d 100644 --- a/packages/contract/src/ai.ts +++ b/packages/contract/src/ai.ts @@ -63,7 +63,13 @@ export const aiDetailSchema = z.object({ className: z.string().nullable(), start: z.string(), end: z.string(), - solved: z.array(solvedProblemSchema), + /** + * 区间内做出来的题数。逐题明细走 `GET /ai/solved` 分页拿 —— + * 一个活跃学生一年能做几百道,整份塞进这个响应没有必要。 + */ + solvedCount: z.number().int(), + /** 每道做出来的题「到首次通过为止提交了几次」,分档放在前端 */ + attempts: z.array(z.number().int()), flowcharts: z.array(flowchartSummarySchema), grade: gradeSchema, tags: z.record(z.string(), z.number().int()), @@ -91,6 +97,12 @@ export const aiDetailSchema = z.object({ * 以前是 `details: z.unknown()` / `duration: z.unknown()` —— 前端算好的整包 POST 回去, * 原样进 prompt 又原样写进 ai_analysis 表,等于让任何登录用户决定喂给模型什么。 */ +/** GET /ai/solved 的分页响应 */ +export const solvedListSchema = z.object({ + results: z.array(solvedProblemSchema), + total: z.number().int(), +}) + export const aiAnalysisRequestSchema = z.object({ start: z.string().min(1), end: z.string().min(1), @@ -150,6 +162,7 @@ export type SolvedProblem = z.infer export type FlowchartSummary = z.infer export type ActivityBucket = z.infer export type AiDetail = z.infer +export type SolvedList = z.infer export type HeatmapItem = z.infer export type AiAnalysisRecord = z.infer export type LoginSummary = z.infer