feat(智能分析): 解题表格改服务端分页,/ai/detail 不再下发整份 solved
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
原来 /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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
This commit is contained in:
@@ -8,9 +8,10 @@ import {
|
|||||||
durationDataSchema,
|
durationDataSchema,
|
||||||
heatmapItemSchema,
|
heatmapItemSchema,
|
||||||
loginSummarySchema,
|
loginSummarySchema,
|
||||||
|
solvedListSchema,
|
||||||
solvedProblemSchema,
|
solvedProblemSchema,
|
||||||
} from "@oj2/contract"
|
} 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 { Hono, type Context } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
@@ -21,7 +22,7 @@ import { JudgeStatus } from "../judge/status"
|
|||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { completeChat, streamChat } from "../services/ai"
|
import { completeChat, streamChat } from "../services/ai"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { isTeacherOrAbove, objectValue, rounded } from "./helpers"
|
import { isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
|
||||||
|
|
||||||
export const aiRoutes = new Hono<AppEnv>()
|
export const aiRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -93,6 +94,85 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
|
|||||||
return target ?? null
|
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<number, number>()
|
||||||
|
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) {
|
async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||||
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
||||||
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||||
@@ -126,59 +206,20 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
const errors = [...errorCounts]
|
const errors = [...errorCounts]
|
||||||
.map(([result, count]) => ({ result, count }))
|
.map(([result, count]) => ({ result, count }))
|
||||||
.sort((a, b) => b.count - a.count || a.result - b.result)
|
.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) })
|
const firstAc = await firstAcQuery(user, start, end)
|
||||||
.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 problemIds = firstAc.map((item) => item.problemId)
|
const problemIds = firstAc.map((item) => item.problemId)
|
||||||
if (!problemIds.length) return aiDetailSchema.parse({
|
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",
|
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 [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
|
||||||
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
buildSolved(user, start, end, firstAc),
|
||||||
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),
|
|
||||||
db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
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)),
|
.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 })
|
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))
|
.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))),
|
.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<number, number>()
|
|
||||||
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<string, number> = {}
|
const tags: Record<string, number> = {}
|
||||||
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
|
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))
|
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))
|
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
|
||||||
return aiDetailSchema.parse({
|
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,
|
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
||||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||||
activity, errors, rankScope: scopeIds ? "class" : "global",
|
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))
|
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) {
|
function shiftMonths(date: Date, months: number) {
|
||||||
const result = new Date(date)
|
const result = new Date(date)
|
||||||
const day = result.getDate()
|
const day = result.getDate()
|
||||||
@@ -414,17 +469,20 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
|
|||||||
const limited = await throttleAi(c)
|
const limited = await throttleAi(c)
|
||||||
if (limited) return limited
|
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),
|
buildDetail(user, parsed.data.start, parsed.data.end),
|
||||||
buildDuration(user, parsed.data.end, parsed.data.duration),
|
buildDuration(user, parsed.data.end, parsed.data.duration),
|
||||||
|
listSolved(user, parsed.data.start, parsed.data.end, 200, 0),
|
||||||
])
|
])
|
||||||
const system = "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
|
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) => {
|
return streamChat(system, prompt, async (analysis) => {
|
||||||
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
|
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
|
||||||
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
|
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
|
||||||
await db.insert(schema.aiAnalysis).values({
|
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,
|
userPrompt: "学习详情与周期数据", analysis, createTime: new Date().toISOString(), userId: user.id, isPinned: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
|
|||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
const { chartKey } = useChartTheme()
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
// solved[].attempts 是后端算的「到首次通过为止的提交次数」,分档放在前端,
|
// detailsData.attempts 是后端算的「每道题到首次通过为止的提交次数」,分档放在前端,
|
||||||
// 想调档位不用改契约
|
// 想调档位不用改契约。逐题的题名不在这里 —— 明细是分页拿的,别为了 tooltip 把全量拉回来
|
||||||
const BUCKETS = [
|
const BUCKETS = [
|
||||||
{ label: "一次过", color: "#18A058", min: 1, max: 1 },
|
{ label: "一次过", color: "#18A058", min: 1, max: 1 },
|
||||||
{ label: "2-3 次", color: "#2080F0", min: 2, max: 3 },
|
{ label: "2-3 次", color: "#2080F0", min: 2, max: 3 },
|
||||||
@@ -40,20 +40,20 @@ const BUCKETS = [
|
|||||||
const buckets = computed(() =>
|
const buckets = computed(() =>
|
||||||
BUCKETS.map((bucket) => ({
|
BUCKETS.map((bucket) => ({
|
||||||
...bucket,
|
...bucket,
|
||||||
problems: aiStore.detailsData.solved.filter(
|
count: aiStore.detailsData.attempts.filter(
|
||||||
(item) => item.attempts >= bucket.min && item.attempts <= bucket.max,
|
(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(() => ({
|
const data = computed(() => ({
|
||||||
labels: buckets.value.map((bucket) => bucket.label),
|
labels: buckets.value.map((bucket) => bucket.label),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "题目数量",
|
label: "题目数量",
|
||||||
data: buckets.value.map((bucket) => bucket.problems.length),
|
data: buckets.value.map((bucket) => bucket.count),
|
||||||
backgroundColor: buckets.value.map((bucket) => bucket.color),
|
backgroundColor: buckets.value.map((bucket) => bucket.color),
|
||||||
borderColor: buckets.value.map((bucket) => bucket.color),
|
borderColor: buckets.value.map((bucket) => bucket.color),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -78,14 +78,11 @@ const options = computed<ChartOptions<"bar">>(() => ({
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => `${ctx.parsed.y} 道题`,
|
label: (ctx) => {
|
||||||
afterLabel: (ctx) => {
|
const value = Number(ctx.parsed.y)
|
||||||
const titles = buckets.value[ctx.dataIndex]!.problems.map(
|
const total = aiStore.detailsData.attempts.length
|
||||||
(item) => `${item.problem.displayId} ${item.problem.title}`,
|
const percent = total ? (value / total) * 100 : 0
|
||||||
)
|
return `${value} 道题(占 ${percent.toFixed(0)}%)`
|
||||||
if (!titles.length) return ""
|
|
||||||
if (titles.length <= 5) return titles
|
|
||||||
return [...titles.slice(0, 4), `… 还有 ${titles.length - 4} 道`]
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,16 +34,13 @@ const difficultyOrder = ["简单", "中等", "困难"]
|
|||||||
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
|
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
|
||||||
|
|
||||||
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
|
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
|
||||||
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有
|
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有。
|
||||||
|
// 直接读后端的 difficulty 聚合 —— 逐题列表现在是分页拿的,前端手上没有全量
|
||||||
const counts = computed(() =>
|
const counts = computed(() =>
|
||||||
difficultyOrder.map(
|
difficultyOrder.map((name) => aiStore.detailsData.difficulty[name] ?? 0),
|
||||||
(name) =>
|
|
||||||
aiStore.detailsData.solved.filter((item) => item.difficulty === name)
|
|
||||||
.length,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const show = computed(() => aiStore.detailsData.solved.length > 0)
|
const show = computed(() => aiStore.detailsData.solvedCount > 0)
|
||||||
|
|
||||||
const data = computed(() => ({
|
const data = computed(() => ({
|
||||||
labels: difficultyOrder,
|
labels: difficultyOrder,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<n-alert
|
<n-alert
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
type="success"
|
type="success"
|
||||||
v-if="aiStore.detailsData.solved.length"
|
v-if="aiStore.detailsData.solvedCount"
|
||||||
>
|
>
|
||||||
<span>{{ durationLabel }},</span>
|
<span>{{ durationLabel }},</span>
|
||||||
<span>你一共解决 </span>
|
<span>你一共解决 </span>
|
||||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
|
||||||
<span> 道题</span>
|
<span> 道题</span>
|
||||||
<span v-if="aiStore.detailsData.contestCount > 0">
|
<span v-if="aiStore.detailsData.contestCount > 0">
|
||||||
,并且参加
|
,并且参加
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-tabs animated v-if="submissions.length && flowcharts.length">
|
<n-tabs animated v-if="hasSolved && flowcharts.length">
|
||||||
<n-tab-pane name="代码提交">
|
<n-tab-pane name="代码提交">
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
remote
|
||||||
striped
|
striped
|
||||||
:data="submissions"
|
:data="aiStore.solvedRows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:max-height="isDesktop ? 1500 : 500"
|
:loading="aiStore.loading.solved"
|
||||||
|
:pagination="solvedPagination"
|
||||||
|
@update:page="aiStore.fetchSolved"
|
||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="流程图提交">
|
<n-tab-pane name="流程图提交">
|
||||||
@@ -13,23 +16,26 @@
|
|||||||
striped
|
striped
|
||||||
:data="flowcharts"
|
:data="flowcharts"
|
||||||
:columns="flowchartsColumns"
|
:columns="flowchartsColumns"
|
||||||
:max-height="isDesktop ? 1500 : 500"
|
:pagination="paginationFor(flowcharts)"
|
||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-else-if="submissions.length"
|
v-else-if="hasSolved"
|
||||||
|
remote
|
||||||
striped
|
striped
|
||||||
:data="submissions"
|
:data="aiStore.solvedRows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:max-height="isDesktop ? 1500 : 500"
|
:loading="aiStore.loading.solved"
|
||||||
|
:pagination="solvedPagination"
|
||||||
|
@update:page="aiStore.fetchSolved"
|
||||||
/>
|
/>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-else-if="flowcharts.length"
|
v-else-if="flowcharts.length"
|
||||||
striped
|
striped
|
||||||
:data="flowcharts"
|
:data="flowcharts"
|
||||||
:columns="flowchartsColumns"
|
:columns="flowchartsColumns"
|
||||||
:max-height="isDesktop ? 1500 : 500"
|
:pagination="paginationFor(flowcharts)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -38,16 +44,30 @@ import { NButton, NTooltip } from "naive-ui"
|
|||||||
import TagTitle from "./TagTitle.vue"
|
import TagTitle from "./TagTitle.vue"
|
||||||
import type { FlowchartSummary, SolvedProblem } from "utils/types"
|
import type { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const hasSolved = computed(() => aiStore.detailsData.solvedCount > 0)
|
||||||
|
|
||||||
const submissions = computed(() => aiStore.detailsData.solved)
|
|
||||||
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
|
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<SolvedProblem>[] = [
|
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||||
{
|
{
|
||||||
title: "完成的题目",
|
title: "完成的题目",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type DurationData,
|
type DurationData,
|
||||||
type HeatmapItem,
|
type HeatmapItem,
|
||||||
type LoginSummary,
|
type LoginSummary,
|
||||||
|
type SolvedList,
|
||||||
type ProblemSet,
|
type ProblemSet,
|
||||||
type ProblemSetBadge,
|
type ProblemSetBadge,
|
||||||
type ProblemSetList,
|
type ProblemSetList,
|
||||||
@@ -302,6 +303,18 @@ export function getAIDetailData(start: string, end: string, username?: string) {
|
|||||||
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
|
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAISolved(
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
username?: string,
|
||||||
|
) {
|
||||||
|
return api.get<SolvedList>("ai/solved", {
|
||||||
|
params: { start, end, offset, limit, username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getAIDurationData(
|
export function getAIDurationData(
|
||||||
end: string,
|
end: string,
|
||||||
duration: string,
|
duration: string,
|
||||||
|
|||||||
@@ -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 { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||||
import {
|
import {
|
||||||
getAIDetailData,
|
getAIDetailData,
|
||||||
getAIDurationData,
|
getAIDurationData,
|
||||||
getAIHeatmapData,
|
getAIHeatmapData,
|
||||||
getAIPinnedReport,
|
getAIPinnedReport,
|
||||||
|
getAISolved,
|
||||||
} from "../api"
|
} from "../api"
|
||||||
|
|
||||||
export const useAIStore = defineStore("ai", () => {
|
export const useAIStore = defineStore("ai", () => {
|
||||||
@@ -23,7 +24,8 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
tags: {},
|
tags: {},
|
||||||
difficulty: {},
|
difficulty: {},
|
||||||
contestCount: 0,
|
contestCount: 0,
|
||||||
solved: [],
|
solvedCount: 0,
|
||||||
|
attempts: [],
|
||||||
flowcharts: [],
|
flowcharts: [],
|
||||||
activity: [],
|
activity: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
@@ -31,10 +33,17 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
})
|
})
|
||||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
|
||||||
|
// 解题明细走服务端分页:一个活跃学生一年几百道题,整份跟着 detail 一起下发没必要
|
||||||
|
const solvedRows = ref<SolvedProblem[]>([])
|
||||||
|
const solvedTotal = ref(0)
|
||||||
|
const solvedPage = ref(1)
|
||||||
|
const solvedPageSize = ref(20)
|
||||||
|
|
||||||
const loading = reactive({
|
const loading = reactive({
|
||||||
fetching: false, // 合并 details 和 duration 的 loading
|
fetching: false, // 合并 details 和 duration 的 loading
|
||||||
ai: false,
|
ai: false,
|
||||||
heatmap: false,
|
heatmap: false,
|
||||||
|
solved: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mdContent = ref("")
|
const mdContent = ref("")
|
||||||
@@ -48,12 +57,13 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
)
|
)
|
||||||
detailsData.start = res.start
|
detailsData.start = res.start
|
||||||
detailsData.end = res.end
|
detailsData.end = res.end
|
||||||
detailsData.solved = res.solved
|
|
||||||
detailsData.grade = res.grade
|
detailsData.grade = res.grade
|
||||||
detailsData.className = res.className
|
detailsData.className = res.className
|
||||||
detailsData.tags = res.tags
|
detailsData.tags = res.tags
|
||||||
detailsData.difficulty = res.difficulty
|
detailsData.difficulty = res.difficulty
|
||||||
detailsData.contestCount = res.contestCount
|
detailsData.contestCount = res.contestCount
|
||||||
|
detailsData.solvedCount = res.solvedCount
|
||||||
|
detailsData.attempts = res.attempts
|
||||||
detailsData.activity = res.activity
|
detailsData.activity = res.activity
|
||||||
detailsData.errors = res.errors
|
detailsData.errors = res.errors
|
||||||
detailsData.rankScope = res.rankScope
|
detailsData.rankScope = res.rankScope
|
||||||
@@ -69,6 +79,25 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
durationData.value = res
|
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() {
|
async function fetchHeatmapData() {
|
||||||
loading.heatmap = true
|
loading.heatmap = true
|
||||||
const res = await getAIHeatmapData(targetUsername.value || undefined)
|
const res = await getAIHeatmapData(targetUsername.value || undefined)
|
||||||
@@ -83,11 +112,14 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
) {
|
) {
|
||||||
rangeStart.value = start
|
rangeStart.value = start
|
||||||
rangeEnd.value = end
|
rangeEnd.value = end
|
||||||
|
// 换时间范围就回到第一页,否则停在第 5 页但新范围只有两页
|
||||||
|
solvedPage.value = 1
|
||||||
loading.fetching = true
|
loading.fetching = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchDetailsData(start, end),
|
fetchDetailsData(start, end),
|
||||||
fetchDurationData(end, duration),
|
fetchDurationData(end, duration),
|
||||||
|
fetchSolved(1),
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
loading.fetching = false
|
loading.fetching = false
|
||||||
@@ -196,6 +228,11 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
return {
|
return {
|
||||||
fetchAnalysisData,
|
fetchAnalysisData,
|
||||||
fetchHeatmapData,
|
fetchHeatmapData,
|
||||||
|
fetchSolved,
|
||||||
|
solvedRows,
|
||||||
|
solvedTotal,
|
||||||
|
solvedPage,
|
||||||
|
solvedPageSize,
|
||||||
fetchAIAnalysis,
|
fetchAIAnalysis,
|
||||||
fetchPinnedReport,
|
fetchPinnedReport,
|
||||||
simulatePinnedStream,
|
simulatePinnedStream,
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ export type {
|
|||||||
DurationData,
|
DurationData,
|
||||||
FlowchartSummary,
|
FlowchartSummary,
|
||||||
SolvedProblem,
|
SolvedProblem,
|
||||||
|
SolvedList,
|
||||||
AiDetail as DetailsData,
|
AiDetail as DetailsData,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ export const aiDetailSchema = z.object({
|
|||||||
className: z.string().nullable(),
|
className: z.string().nullable(),
|
||||||
start: z.string(),
|
start: z.string(),
|
||||||
end: 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),
|
flowcharts: z.array(flowchartSummarySchema),
|
||||||
grade: gradeSchema,
|
grade: gradeSchema,
|
||||||
tags: z.record(z.string(), z.number().int()),
|
tags: z.record(z.string(), z.number().int()),
|
||||||
@@ -91,6 +97,12 @@ export const aiDetailSchema = z.object({
|
|||||||
* 以前是 `details: z.unknown()` / `duration: z.unknown()` —— 前端算好的整包 POST 回去,
|
* 以前是 `details: z.unknown()` / `duration: z.unknown()` —— 前端算好的整包 POST 回去,
|
||||||
* 原样进 prompt 又原样写进 ai_analysis 表,等于让任何登录用户决定喂给模型什么。
|
* 原样进 prompt 又原样写进 ai_analysis 表,等于让任何登录用户决定喂给模型什么。
|
||||||
*/
|
*/
|
||||||
|
/** GET /ai/solved 的分页响应 */
|
||||||
|
export const solvedListSchema = z.object({
|
||||||
|
results: z.array(solvedProblemSchema),
|
||||||
|
total: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
export const aiAnalysisRequestSchema = z.object({
|
export const aiAnalysisRequestSchema = z.object({
|
||||||
start: z.string().min(1),
|
start: z.string().min(1),
|
||||||
end: z.string().min(1),
|
end: z.string().min(1),
|
||||||
@@ -150,6 +162,7 @@ export type SolvedProblem = z.infer<typeof solvedProblemSchema>
|
|||||||
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
||||||
export type ActivityBucket = z.infer<typeof activityBucketSchema>
|
export type ActivityBucket = z.infer<typeof activityBucketSchema>
|
||||||
export type AiDetail = z.infer<typeof aiDetailSchema>
|
export type AiDetail = z.infer<typeof aiDetailSchema>
|
||||||
|
export type SolvedList = z.infer<typeof solvedListSchema>
|
||||||
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
||||||
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
||||||
export type LoginSummary = z.infer<typeof loginSummarySchema>
|
export type LoginSummary = z.infer<typeof loginSummarySchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user