Compare commits

...
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 e4abbc1645 feat(智能分析): 页面改成每节课一行,一眼看出哪道一次就对、哪道交了很多次、哪道还没做对
Deploy / deploy (push) Canceled after 0s
一年热力图、半年月度图、难度分布、时间活跃度这些对多数学生大半空白的图都去掉了;
右边是差一点的(接着做)、错在哪、知识点;老师看学生能 ‹ › 翻同班下一个人。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:20:34 +08:00
xuyueandClaude Opus 5.5 890e731a1c feat(智能分析): 后端改成按「每节课」给数据,AI 小结跟着改,不再把交了好几次的题夸成做得好
GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、
知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、
/ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:20:34 +08:00
31 changed files with 1850 additions and 2431 deletions

No files matched your search

+83 -157
View File
@@ -5,15 +5,15 @@ import {
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
HINT_MIN_FAILURES, HINT_MIN_FAILURES,
LEARNING_PERIOD_LABELS,
learningPeriodSchema,
type AiAnalysisRecord, type AiAnalysisRecord,
type AiDetail,
type AiHintDone, type AiHintDone,
type DurationData,
type HintDiagnosis, type HintDiagnosis,
type HeatmapItem, type LearningItem,
type SolvedProblem, type LearningReport,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, eq, gte, inArray, lte, sql } from "drizzle-orm" import { and, desc, eq, inArray, or, 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"
@@ -23,12 +23,12 @@ import { JudgeStatus, judgeStatusName } from "../judge/status"
import { failure, parseBody, readJson, success } from "../http" import { failure, parseBody, readJson, success } from "../http"
import { streamChat, streamWhole } from "../services/ai" import { streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter" import { generateFilteredHint } from "../services/hint-filter"
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats" import { buildReport } from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level" import { decideHintLevel } from "../services/hint-level"
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis" import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
import { classDetail, classPk } from "../services/ranking" import { classDetail, classPk } from "../services/ranking"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time" import { dayNumber, localWeekday } from "../time"
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers" import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
export const aiRoutes = new Hono<AppEnv>() export const aiRoutes = new Hono<AppEnv>()
@@ -68,83 +68,41 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
return target ?? null return target ?? null
} }
aiRoutes.get("/ai/detail", requireAuth, async (c) => { aiRoutes.get("/ai/report", requireAuth, async (c) => {
const start = c.req.query("start") const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term")
const end = c.req.query("end") if (!period.success)
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { return failure(c, 400, "invalid-period", "period must be week, month, term or all")
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c) const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found") if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(c, await buildDetail(user, start, end)) // 老师看别人时给出同班前后一个人,好在页面上 ‹ › 翻
const viewingOther = user.id !== c.get("user")!.id
return success(c, await buildReport(user, period.data, { neighbors: viewingOther }))
}) })
aiRoutes.get("/ai/solved", requireAuth, async (c) => { /**
const start = c.req.query("start") * 页面上「AI 帮你分析」那张卡要显示的小结:老师在后台钉住的那份优先(不分时间段),
const end = c.req.query("end") * 否则是这个时间段最近生成的一份。都没有就是 null,前端出「AI 帮我分析」按钮。
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") aiRoutes.get("/ai/analysis/latest", requireAuth, async (c) => {
} const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term")
if (!period.success)
return failure(c, 400, "invalid-period", "period must be week, month, term or all")
const user = await targetUser(c) const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found") 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))
})
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
const endText = c.req.query("end")
if (!endText || Number.isNaN(Date.parse(endText)))
return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(c, await buildDuration(user, endText, c.req.query("duration") ?? "months:1"))
})
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const end = new Date()
// 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。
// 整段以东八区的**日历日序号**为单位算(`dayNumber` / `dayText`),不构造本地 Date。
const today = dayNumber(calendarDay(end))
const mondayOffset = (localWeekday(today) + 6) % 7
const firstMonday = today - mondayOffset - 52 * 7
// SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表
const date = sql<string>`date(${localTime(schema.submission.createTime)})::text`
const rows = await db
.select({ date, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, new Date((firstMonday - 1) * 864e5).toISOString()),
lte(schema.submission.createTime, new Date(end.getTime() + 864e5).toISOString()),
),
)
.groupBy(date)
.orderBy(date)
const counts = new Map(rows.map((row) => [row.date, row.value]))
return success(
c,
Array.from({ length: 53 }, (_, week) => {
const monday = firstMonday + week * 7
let value = 0
for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0
// timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
}),
)
})
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
const [row] = await db const [row] = await db
.select({ analysis: schema.aiAnalysis, username: schema.user.username }) .select({ analysis: schema.aiAnalysis, username: schema.user.username })
.from(schema.aiAnalysis) .from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where( .where(
and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true)), and(
eq(schema.aiAnalysis.userId, user.id),
or(
eq(schema.aiAnalysis.isPinned, true),
sql`${schema.aiAnalysis.data}->>'period' = ${period.data}`,
),
),
) )
.orderBy(desc(schema.aiAnalysis.isPinned), desc(schema.aiAnalysis.createTime))
.limit(1) .limit(1)
if (!row) return success(c, null) if (!row) return success(c, null)
return success(c, { return success(c, {
@@ -159,130 +117,98 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
} satisfies AiAnalysisRecord) } satisfies AiAnalysisRecord)
}) })
const ANALYSIS_RANGE: Record<string, string> = { const WEEKDAY = "日一二三四五六"
"hours:1": "这一节课",
"hours:2": "这两节课",
"days:1": "最近一天",
"weeks:1": "最近一周",
"months:1": "最近一个月",
"months:2": "最近两个月",
"months:6": "最近半年",
"years:1": "最近一年",
}
/** /**
* 喂给模型的学情数据,全部翻成中文、带单位。原来是 detail / duration 原样 JSON 进 * 喂给模型的学情数据,全部翻成中文、带单位,和页面同一份(buildReport)。原来是
* prompt,模型读不懂裸编码:「错误类型 -1」(答案错误)被说成编译错误, * detail / duration 原样 JSON 进 prompt,模型读不懂裸编码:「错误类型 -1」(答案错误)
* `period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。 * 被说成编译错误,`period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。
* *
* 时间活跃度不给:八成提交在课上,几点做题是课表定的,模型只会据此编出 * 节奏按「每节课」给,不再按从现在往回推的一周一周切 —— 那样切出来的桶边界在半夜,
* 「你是早起鸟」「改到工作日分散练」这类建议。名字和班级也不给,报告里一律称「你」。 * 标签写「9月23日起」其实是 24 日起,模型据此说「23 日那周一道没做」,而那天做对了两道。
* 名字和班级不给,报告里一律称「你」。
*/ */
function analysisFacts( function analysisFacts(report: LearningReport) {
range: string, const result = (item: LearningItem) =>
details: AiDetail, item.status === "first"
periods: DurationData[], ? "一次就对"
solved: SolvedProblem[], : item.status === "tries"
) { ? `交了 ${item.attempts} 次才对`
const scope = details.rankScope === "class" ? "班上" : "全站" : item.status === "fail"
// 两个超时码合成一种说法;判题机自己崩的不算学生的错 ? `交了 ${item.attempts} 次还没做对`
: "没交"
const problem = (item: LearningItem) => ({ 题目: item.title, 结果: result(item) })
const date = (day: string) => {
const [, month, date] = day.split("-").map(Number)
return `${month}月${date}日 周${WEEKDAY[localWeekday(dayNumber(day))]}`
}
// 两个超时码合成一种说法
const errors = new Map<string, number>() const errors = new Map<string, number>()
for (const item of details.errors) { for (const item of report.errors) {
if (item.result === JudgeStatus.SYSTEM_ERROR) continue
const name = judgeStatusName(item.result) const name = judgeStatusName(item.result)
errors.set(name, (errors.get(name) ?? 0) + item.count) errors.set(name, (errors.get(name) ?? 0) + item.count)
} }
const monthDay = (iso: string) => {
const [, month, day] = calendarDay(iso).split("-").map(Number)
return `${month}月${day}日`
}
// 一周以内的范围,buildDuration 给的是前四周,和「这段时间」对不上,不给
const showTrend = ["months:1", "months:2", "months:6", "years:1"].includes(range)
return { return {
时间段: ANALYSIS_RANGE[range] ?? "这段时间", 时间段: LEARNING_PERIOD_LABELS[report.period],
做对的题数: details.solvedCount, 做对的题数: report.solved,
一次就对的题数: details.attempts.filter((value) => value === 1).length, 一次就对的题数: report.firstTry,
难度: details.difficulty, 提交次数: report.submissionCount,
做得最多的知识点: details.tags, ...(report.classStats && {
总等级: details.grade || "无", 班上中间那位做对的题数: report.classStats.medianSolved,
班上中间那位一次就对的百分比: report.classStats.medianFirstPercent,
}),
没通过的提交: [...errors].map(([结果, 次数]) => ({ 结果, 次数 })), 没通过的提交: [...errors].map(([结果, 次数]) => ({ 结果, 次数 })),
...(showTrend && { 做过的知识点: report.knowledge
分段: periods.map((item) => ({ .filter((item) => item.tried)
时间: `${monthDay(item.start)}起的一${item.unit === "weeks" ? "周" : "个月"}`, .map((item) => ({ 知识点: item.name, 做对几道: item.solved })),
做对几道题: item.problemCount, // 新的在前;「全部」可能有上百天,只给最近 30 天,够模型说节奏
提交次数: item.submissionCount, 每天: report.days.slice(0, 30).map((day) => ({
等级: item.grade || "无", 日期: date(day.day),
})), ...(day.lesson.length && { 课上的题: day.lesson.map(problem) }),
}), ...(day.own.length && { 自己练的题: day.own.map(problem) }),
...(details.flowcharts.length && {
流程图: details.flowcharts.map((item) => ({
题目: item.problemTitle,
画了几次: item.submissionCount,
最好等级: item.bestGrade || "无",
})),
}),
做对的题: solved.map((item) => ({
题目: item.problem.title,
难度: item.difficulty,
交了几次才对: item.attempts,
...(item.periodRank && {
第几个做对: `${scope}第 ${item.periodRank} 个(共 ${item.periodAcCount} 人做对)`,
}),
等级: item.grade || "无",
...(item.problem.contestTitle && { 课堂测验: item.problem.contestTitle }),
})), })),
} }
} }
aiRoutes.post("/ai/analysis", requireAuth, async (c) => { aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = await parseBody(c, aiAnalysisRequestSchema, "start, end and duration are required") const parsed = await parseBody(c, aiAnalysisRequestSchema, "period is required")
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
if (Number.isNaN(Date.parse(parsed.data.start)) || Number.isNaN(Date.parse(parsed.data.end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
// 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己 // 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己
const user = await targetUser(c, parsed.data.username) const user = await targetUser(c, parsed.data.username)
if (!user) return failure(c, 404, "user-not-found", "User not found") if (!user) return failure(c, 404, "user-not-found", "User not found")
const limited = await throttleAi(c) const limited = await throttleAi(c)
if (limited) return limited if (limited) return limited
// 学情数据一律服务端重算,客户端只说看谁、哪段时间 // 学情数据一律服务端重算,客户端只说看谁、哪段时间
// detail 现在只带聚合,逐题明细单独取一页给模型看。顺带把喂进 prompt 的条数 const report = await buildReport(user, parsed.data.period, { neighbors: false })
// 卡在 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 = [ const system = [
"你是中职学校的编程课老师,在给自己班上的一位学生写学习小结,学生会在网站上自己读到。", "你是中职学校的编程课老师,在给自己班上的一位学生写学习小结,学生会在网站上自己读到。",
"下面是这位学生在判题网站上的做题数据(JSON)。页面上方已经有图表,不用把数字挨个复述一遍。", "下面是这位学生在判题网站上的做题数据(JSON)。页面上已经按天列出了每道题,不用把数字挨个复述一遍。",
"用中文 Markdown 写,总共 300 字以内,分四段,每段一到三句,段首加粗:", "用中文 Markdown 写,总共 300 字以内,分四段,每段一到三句,段首加粗:",
"**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「分段」里的数说)、", "**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「每天」里的说)、",
"**做得好的**(点出具体题目名)、", "**做得好的**(点出具体题目名,挑一次就对的题;交了好几次才对的不算做得好)、",
"**要注意的**(错得最多的是哪种,教一个具体的检查办法:答案错误就自己多想几组输入试,特别是最大最小的情况;", "**要注意的**(错得最多的是哪种,教一个具体的检查办法:答案错误就自己多想几组输入试,特别是最大最小的情况;",
"编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;交了好几次才对的题点出名字)、", "编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;",
"**下一步**(一两件下节课就能做的事,比如把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。", "交了好几次才对、还没做对、课上没交的题点出名字)、",
"**下一步**(一两件下节课就能做的事,比如把还没做对的题接着做完、把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。",
"最后另起一段,只写一句鼓励的话,要和这位学生的数据挂钩,不要空口号。", "最后另起一段,只写一句鼓励的话,要和这位学生的数据挂钩,不要空口号。",
"用「你」称呼学生,不要写名字。说大白话,不要英文和术语,不要「追」「对手」这类拿同学比的说法。", "用「你」称呼学生,不要写名字。说大白话,不要英文和术语,不要「追」「对手」这类拿同学比的说法。",
"只用数据里有的数字和题目名,不要编;数据里没有的方面就不写(比如错误次数没按题目分,就别说错在哪类题上)。", "只用数据里有的数字和题目名,不要编;数据里没有的方面就不写(比如错误次数没按题目分,就别说错在哪类题上)。",
"不要表格、标题、代码块、字符画、分割线,不要 emoji。", "不要表格、标题、代码块、字符画、分割线,不要 emoji。",
"数据说明:等级 S/A/B/C 看的是同一道题你是第几个做对的(前 10% 是 S、前 35% 是 A、前 75% 是 B),", "数据说明:「课上的题」是班上那天一起做的题,「没交」是班上做了、这位学生一次没交;「自己练的题」是课外自己找来做的。",
"说明做得快不快,不说明代码好不好。比赛是老师安排的课堂测验,不要建议学生多参加比赛。",
"如果这段时间一道题都没做对,就简短说说看到的情况,鼓励下节课先把老师布置的题做出来。", "如果这段时间一道题都没做对,就简短说说看到的情况,鼓励下节课先把老师布置的题做出来。",
].join("") ].join("")
const prompt = JSON.stringify( const prompt = JSON.stringify(analysisFacts(report))
analysisFacts(parsed.data.duration, details, duration, solved.results),
)
return streamChat(system, prompt, { return streamChat(system, prompt, {
onComplete: async (analysis) => { onComplete: async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的 // 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到 // GET /ai/analysis/latest 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({ await db.insert(schema.aiAnalysis).values({
provider: config.aiProvider, provider: config.aiProvider,
model: config.aiModel, model: config.aiModel,
data: { details, duration, solved: solved.results }, // period 是 /ai/analysis/latest 按时间段找回这份小结的依据
data: { period: report.period, report },
systemPrompt: system, systemPrompt: system,
userPrompt: "学习详情与周期数据", userPrompt: prompt,
analysis, analysis,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
userId: user.id, userId: user.id,
+344 -460
View File
@@ -1,507 +1,391 @@
/** /**
* 学情统计:AI 学情分析页(/ai/detail、/ai/duration、/ai/solved)和喂给模型的 * 智能分析:页面(GET /ai/report)和喂给 AI 小结的学情数据都从这里算。
* 数据都从这里算。原来整段放在 routes/ai.ts 里,占了那个文件六成;它们只是查询, *
* 不碰 Context,挪出来 route 文件只剩「鉴权 → 取参 → 调这里 → 回包」。 * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
*
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
* (比赛是课堂测验,题目也不在题库里)。
*/ */
import { import {
type AiDetail, FLOWCHART_PASS_GRADES,
type DurationData, STUDENT_ROLES,
type Grade, type LearningDay,
type SolvedList, type LearningItem,
type SolvedProblem, type LearningPeriod,
type LearningReport,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, countDistinct, eq, gte, inArray, lte, min, sql } from "drizzle-orm" import { and, asc, count, desc, eq, gte, inArray, isNull } from "drizzle-orm"
import { sql } from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, type JudgeStatusValue } from "../judge/status" import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
import { rounded } from "../routes/helpers" import { calendarDay, monthStart, termStart, weekStart } from "../time"
import { localTime, shiftMonthsByCalendar } from "../time"
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const difficultyNames: Record<string, string> = {
Low: "简单", /** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
Mid: "中等", const LESSON_MIN_USERS = 5
High: "困难",
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
const NOT_STUDENT_ERRORS: number[] = [
JudgeStatus.SYSTEM_ERROR,
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]
export function periodStart(period: LearningPeriod, now = new Date()): string | null {
if (period === "week") return weekStart(now)
if (period === "month") return monthStart(now)
if (period === "term") return termStart(now)
return null
} }
function grade(rank: number | null, count: number, reference = count): Grade { interface Row {
if (!rank || count <= 0) return "C" userId: number
const percentile = ((rank - 1) / count) * 100 problemId: number
let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" time: number
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value solved: boolean
return value /** 代码提交的判题状态;流程图为 null */
result: number | null
} }
function averageGrade(grades: Grade[]): Grade { /** 代码提交(不含比赛)和流程图提交并成一张,按时间先后 */
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 } async function activity(userIds: number[], start: string | null): Promise<Row[]> {
const values = grades.flatMap((item) => weights[item] ?? []) const epoch = (column: typeof schema.submission.createTime) =>
if (!values.length) return "" sql<number>`extract(epoch from ${column}) * 1000`.mapWith(Number)
const average = values.reduce((sum, value) => sum + value, 0) / values.length const [code, flow] = await Promise.all([
return average >= 3.5 ? "S" : average >= 2.5 ? "A" : average >= 1.5 ? "B" : "C"
}
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,不必把整年算一遍。
*/
export 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 db
.select({ .select({
userId: schema.submission.userId, userId: schema.submission.userId,
problemId: schema.submission.problemId, problemId: schema.submission.problemId,
first: min(schema.submission.createTime), time: epoch(schema.submission.createTime),
result: schema.submission.result,
}) })
.from(schema.submission) .from(schema.submission)
.where( .where(
and( and(
inArray(schema.submission.result, accepted), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, problemIds), isNull(schema.submission.contestId),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined, start ? gte(schema.submission.createTime, start) : 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 {
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,
} satisfies SolvedProblem
})
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds }
}
/** 分页版:只算这一页的题 */
export 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 db
.select({ value: countDistinct(schema.submission.problemId) }) .select({
.from(schema.submission) userId: schema.flowchartSubmission.userId,
.where( problemId: schema.flowchartSubmission.problemId,
and( time: sql<number>`extract(epoch from ${schema.flowchartSubmission.createTime}) * 1000`.mapWith(
eq(schema.submission.userId, user.id), Number,
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
), ),
), status: schema.flowchartSubmission.status,
]) grade: schema.flowchartSubmission.aiGrade,
const { solved } = await buildSolved(user, start, end, firstAc)
return {
results: solved,
total: totalRows[0]?.value ?? 0,
} satisfies SolvedList
}
export async function buildDetail(user: AuthUser, start: string, end: string) {
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
// 星期和小时都按东八区取,和热力图同口径
const weekday =
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number)
const period =
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
Number,
)
const activityRows = await db
.select({ weekday, period, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(weekday, period)
const activity = activityRows.map((row) => ({
weekday: row.weekday,
period: row.period,
count: row.value,
}))
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
const submissions = await db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>()
for (const row of submissions) {
if (!settledFail(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const errors = [...errorCounts]
.map(([result, count]) => ({ result, count }))
.sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
username: user.username,
className: user.className,
start,
end,
solvedCount: 0,
attempts: [],
flowcharts: [],
grade: "",
tags: {},
difficulty: {},
contestCount: 0,
activity,
errors,
rankScope: "global",
} satisfies AiDetail
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) .from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where( .where(
and( and(
eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.userId, userIds),
eq(schema.flowchartSubmission.status, 2), start ? gte(schema.flowchartSubmission.createTime, start) : undefined,
gte(schema.flowchartSubmission.createTime, start),
lte(schema.flowchartSubmission.createTime, end),
), ),
), ),
]) ])
const tags: Record<string, number> = {} const pass: readonly string[] = FLOWCHART_PASS_GRADES
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1 return [
const topTags = Object.fromEntries( ...code.map((row) => ({ ...row, solved: accepted.includes(row.result) })),
Object.entries(tags) ...flow.map((row) => ({
.sort((a, b) => b[1] - a[1]) userId: row.userId,
.slice(0, 5), problemId: row.problemId,
) time: row.time,
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 } solved: row.status === 2 && pass.includes(row.grade ?? ""),
for (const item of problems) { result: null,
const name = difficultyNames[item.problem.difficulty] ?? "中等" })),
difficulty[name] = (difficulty[name] ?? 0) + 1 ].sort((a, b) => a.time - b.time)
}
const flowGroups = new Map<string, typeof flowRows>()
for (const flow of flowRows)
flowGroups.set(flow.displayId, [...(flowGroups.get(flow.displayId) ?? []), flow])
const flowcharts = [...flowGroups]
.map(([displayId, rows]) => {
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
const top = rows.reduce(
(best, row) => ((row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best),
rows[0]!,
)
return {
problemId: displayId,
problemTitle: rows[0]?.title ?? "",
submissionCount: rows.length,
bestScore: Math.max(0, top.flow.aiScore ?? 0),
bestGrade: top.flow.aiGrade ?? "",
latestSubmissionTime:
rows
.map((row) => row.flow.createTime)
.sort()
.at(-1) ?? start,
avgScore: rounded(
scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0,
0,
),
}
})
.sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
return {
username: 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",
} satisfies AiDetail
} }
export async function buildDuration(user: AuthUser, endText: string, duration: string) { /** 一个人在一道题上:第一次交的时刻、做对的时刻、到做对为止交了几次、没做对时最后一次的结果 */
const config = interface Track {
duration === "months:2" first: number
? { solvedAt: number | null
count: 8, attempts: number
unit: "weeks", last: number | null
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5), }
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
} function median(values: number[]) {
: duration === "months:6" if (!values.length) return 0
? { const sorted = [...values].sort((a, b) => a - b)
count: 6, const mid = Math.floor(sorted.length / 2)
unit: "months", return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
rewind: (date: Date) => shiftMonthsByCalendar(date, -7), }
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
} export async function buildReport(
: duration === "years:1" target: { id: number; username: string; className: string | null },
? { period: LearningPeriod,
count: 12, options: { neighbors: boolean },
unit: "months", ): Promise<LearningReport> {
rewind: (date: Date) => shiftMonthsByCalendar(date, -13), const now = new Date()
advance: (date: Date) => shiftMonthsByCalendar(date, 1), const start = periodStart(period, now)
} const members = target.className
: {
count: 4,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
let cursor = config.rewind(new Date(endText))
const buckets: { start: Date; end: Date }[] = []
for (let index = 0; index < config.count; index++) {
const start = config.advance(cursor)
buckets.push({ start, end: config.advance(start) })
cursor = start
}
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
const rows = await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(Number),
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
),
)
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
const solvedIds = [
...new Set(rows.filter((row) => accepted.includes(row.result)).map((row) => row.problemId)),
]
const classUsers = user.className
? await db ? await db
.select({ id: schema.user.id }) .select({ id: schema.user.id, username: schema.user.username })
.from(schema.user) .from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const peers = solvedIds.length
? await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
userId: schema.submission.userId,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where( .where(
and( and(
inArray(schema.submission.result, accepted), eq(schema.user.className, target.className),
inArray(schema.submission.problemId, solvedIds), eq(schema.user.isDisabled, false),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()), inArray(schema.user.adminType, [...STUDENT_ROLES]),
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
), ),
) )
.orderBy(asc(schema.user.username))
: [] : []
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题 const memberIds = new Set(members.map((item) => item.id))
const peersByProblem = new Map<number, typeof peers>() const [rows, plans] = await Promise.all([
for (const row of peers) activity([...new Set([target.id, ...memberIds])], start),
peersByProblem.set(row.problemId, [...(peersByProblem.get(row.problemId) ?? []), row]) target.className
? db
.select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds })
.from(schema.classLesson)
.where(
and(
eq(schema.classLesson.className, target.className),
start ? gte(schema.classLesson.day, calendarDay(start)) : undefined,
),
)
: [],
])
function bucketGrade(problemIds: number[], from: number, to: number) { // 每人每题的轨迹,和同班「某天某题」的人数、最早时刻(推断这节课、排题目顺序用)
return averageGrade( const tracks = new Map<number, Map<number, Track>>()
problemIds.map((problemId) => { const dayUsers = new Map<string, Set<number>>()
const firstAc = new Map<number, number>() const dayFirst = new Map<string, number>()
for (const row of peersByProblem.get(problemId) ?? []) { const mine: Row[] = []
if (row.time < from || row.time > to) continue for (const row of rows) {
const seen = firstAc.get(row.userId) if (row.userId === target.id) mine.push(row)
if (seen === undefined || row.time < seen) firstAc.set(row.userId, row.time) let byProblem = tracks.get(row.userId)
} if (!byProblem) tracks.set(row.userId, (byProblem = new Map()))
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0]) let track = byProblem.get(row.problemId)
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null if (!track)
return grade(rank, ordered.length) byProblem.set(
}), row.problemId,
(track = { first: row.time, solvedAt: null, attempts: 0, last: null }),
)
if (track.solvedAt === null) {
track.attempts += 1
if (row.solved) track.solvedAt = row.time
else if (row.result !== null) track.last = row.result
}
if (!memberIds.has(row.userId)) continue
const key = `${calendarDay(row.time)}|${row.problemId}`
let users = dayUsers.get(key)
if (!users) dayUsers.set(key, (users = new Set()))
users.add(row.userId)
if (!dayFirst.has(key)) dayFirst.set(key, row.time)
}
const usersOn = (day: string, problemId: number) => dayUsers.get(`${day}|${problemId}`)?.size ?? 0
// 一道题只归到一天:老师布置过的归布置的那天(布置了好几天就挑人最多的),
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
const lessonDay = new Map<number, string>()
const planOrder = new Map<string, number>()
for (const plan of plans) {
plan.problemIds.forEach((problemId, index) => {
planOrder.set(`${plan.day}|${problemId}`, index)
const current = lessonDay.get(problemId)
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId))
lessonDay.set(problemId, plan.day)
})
}
const planned = new Set(lessonDay.keys())
for (const [key, users] of dayUsers) {
if (users.size < LESSON_MIN_USERS) continue
const [day, id] = key.split("|") as [string, string]
const problemId = Number(id)
if (planned.has(problemId)) continue
const current = lessonDay.get(problemId)
if (
!current ||
users.size > usersOn(current, problemId) ||
(users.size === usersOn(current, problemId) && day > current)
) )
lessonDay.set(problemId, day)
} }
return buckets.map((bucket, index) => { const myTracks = tracks.get(target.id) ?? new Map<number, Track>()
const from = bucket.start.getTime() const problemIds = [...new Set([...lessonDay.keys(), ...myTracks.keys()])]
const to = bucket.end.getTime() const [problems, knowledgeTags, myTags] = await Promise.all([
const inRange = rows.filter((row) => row.time >= from && row.time <= to) problemIds.length
const acceptedRows = inRange.filter((row) => accepted.includes(row.result)) ? db
const solved = [...new Set(acceptedRows.map((row) => row.problemId))] .select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
visible: schema.problem.visible,
contestId: schema.problem.contestId,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: [],
db
.select({ name: schema.problemTag.name, total: count() })
.from(schema.problemTag)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(schema.problem, eq(schema.problem.id, schema.problemTags.problemId))
.where(
and(
eq(schema.problemTag.category, "knowledge"),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.groupBy(schema.problemTag.name)
.orderBy(desc(count()), asc(schema.problemTag.name)),
myTracks.size
? db
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(
and(
eq(schema.problemTag.category, "knowledge"),
inArray(schema.problemTags.problemId, [...myTracks.keys()]),
),
)
: [],
])
const problemById = new Map(problems.map((item) => [item.id, item]))
// 被藏起来的题、比赛题不当「这节课的题」—— 学生点进去是 404
for (const problemId of lessonDay.keys()) {
const problem = problemById.get(problemId)
if (!problem?.visible || problem.contestId !== null) lessonDay.delete(problemId)
}
function item(problemId: number): LearningItem {
const problem = problemById.get(problemId)
const track = myTracks.get(problemId)
let rank: number | null = null
let classSolved: number | null = null
if (memberIds.size) {
const solvedAt: [number, number][] = []
for (const id of memberIds) {
const at = tracks.get(id)?.get(problemId)?.solvedAt
if (at != null) solvedAt.push([at, id])
}
solvedAt.sort((a, b) => a[0] - b[0] || a[1] - b[1])
classSolved = solvedAt.length
const index = solvedAt.findIndex(([, id]) => id === target.id)
rank = index >= 0 ? index + 1 : null
}
const status = !track
? "none"
: track.solvedAt === null
? "fail"
: track.attempts === 1
? "first"
: "tries"
return { return {
unit: config.unit, displayId: problem?.displayId ?? "",
index: config.count - 1 - index, title: problem?.title ?? "",
start: bucket.start.toISOString(), status,
end: bucket.end.toISOString(), attempts: track?.attempts ?? 0,
grade: solved.length ? bucketGrade(solved, from, to) : "", rank,
problemCount: solved.length, classSolved,
acceptedCount: acceptedRows.length, lastResult: track && track.solvedAt === null ? track.last : null,
submissionCount: inRange.length, }
} satisfies DurationData }
})
const byDay = new Map<string, { lesson: number[]; own: number[] }>()
const slot = (day: string) => {
let entry = byDay.get(day)
if (!entry) byDay.set(day, (entry = { lesson: [], own: [] }))
return entry
}
for (const [problemId, day] of lessonDay) slot(day).lesson.push(problemId)
for (const [problemId, track] of myTracks)
if (!lessonDay.has(problemId)) slot(calendarDay(track.first)).own.push(problemId)
const order = (day: string, problemId: number) =>
planOrder.get(`${day}|${problemId}`) ?? 1e6 + (dayFirst.get(`${day}|${problemId}`) ?? 0) / 1e3
const days: LearningDay[] = [...byDay]
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
.map(([day, entry]) => ({
day,
lesson: entry.lesson.sort((a, b) => order(day, a) - order(day, b)).map(item),
own: entry.own.sort((a, b) => myTracks.get(a)!.first - myTracks.get(b)!.first).map(item),
}))
const solvedIds = [...myTracks].filter(([, track]) => track.solvedAt !== null)
const solved = solvedIds.length
let classStats: LearningReport["classStats"] = null
if (memberIds.size) {
const counts: number[] = []
const firstPercents: number[] = []
for (const id of memberIds) {
const byProblem = tracks.get(id)
if (!byProblem) continue
let done = 0
let first = 0
for (const track of byProblem.values()) {
if (track.solvedAt === null) continue
done += 1
if (track.attempts === 1) first += 1
}
counts.push(done)
if (done) firstPercents.push((first / done) * 100)
}
if (counts.length)
classStats = {
size: counts.length,
medianSolved: median(counts),
medianFirstPercent: Math.round(median(firstPercents)),
rank: 1 + counts.filter((value) => value > solved).length,
}
}
const errorCounts = new Map<number, number>()
for (const row of mine) {
if (row.solved || row.result === null || NOT_STUDENT_ERRORS.includes(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const tagCounts = new Map<string, { solved: number; tried: number }>()
for (const tag of myTags) {
const entry = tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }
entry.tried += 1
if (myTracks.get(tag.problemId)?.solvedAt != null) entry.solved += 1
tagCounts.set(tag.name, entry)
}
let neighbors: LearningReport["neighbors"] = null
if (options.neighbors && members.length) {
const index = members.findIndex((item) => item.id === target.id)
if (index >= 0)
neighbors = {
prev: members[index - 1]?.username ?? null,
next: members[index + 1]?.username ?? null,
}
}
return {
username: target.username,
className: target.className,
period,
start,
end: now.toISOString(),
solved,
firstTry: solvedIds.filter(([, track]) => track.attempts === 1).length,
lessonCount: days.filter((day) => day.lesson.length).length,
submissionCount: mine.length,
unsolved: [...myTracks]
.filter(([, track]) => track.solvedAt === null)
.sort((a, b) => b[1].attempts - a[1].attempts)
.map(([problemId]) => item(problemId)),
classStats,
days,
errors: [...errorCounts]
.map(([result, value]) => ({ result, count: value }))
.sort((a, b) => b.count - a.count || a.result - b.result),
knowledge: knowledgeTags.map((tag) => ({
name: tag.name,
...(tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }),
})),
neighbors,
} satisfies LearningReport
} }
+8
View File
@@ -95,6 +95,14 @@ export function termStart(now: Date | number | string = new Date()): string {
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString() return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
} }
/** 「东八区本月 1 日」的零点,返回 ISO 字符串 */
export function monthStart(now: Date | number | string = new Date()): string {
const wall = toWallClock(now)
return fromWallClock(
new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), 1)),
).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date { export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant) const wall = toWallClock(instant)
+320 -84
View File
@@ -1,107 +1,343 @@
<template>
<n-spin class="oj-page" :show="aiStore.loading.fetching" :delay="50">
<n-flex vertical :size="20">
<n-flex align="center" justify="space-between">
<h2 class="title">请选择时间范围,智能分析学习情况</h2>
<n-flex align="center">
<n-input
v-if="userStore.isTeacherOrAbove"
v-model:value="urlUsername"
placeholder="查看指定用户"
clearable
style="width: 140px"
@change="onUsernameChange"
@clear="onUsernameChange"
/>
<n-select style="width: 140px" :options="options" v-model:value="urlDuration" />
</n-flex>
</n-flex>
<Overview />
<Heatmap />
<DurationChart />
<!-- 用弹性排布而不是两列网格:知识点分布在没有标签时整张卡不渲染,
固定两列会空掉一半;flex 下剩的那张自动占满整行 -->
<n-flex class="pair" :size="20">
<DifficultyGradeChart />
<TagsChart />
</n-flex>
<n-flex class="pair" :size="20">
<ErrorTypeChart />
<AttemptsChart />
</n-flex>
<n-flex class="pair" :size="20">
<TimeActivityHeatmap />
<FlowchartScoreChart />
</n-flex>
<SolvedTable />
<AI />
</n-flex>
</n-spin>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns" import {
LEARNING_PERIOD_LABELS,
learningPeriodSchema,
type LearningPeriod,
type LearningReport,
} from "@oj2/contract"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue" import { useThemeVars } from "naive-ui"
import TagsChart from "./components/TagsChart.vue" import { useBreakpoints } from "shared/composables/breakpoints"
import ErrorTypeChart from "./components/ErrorTypeChart.vue"
import AttemptsChart from "./components/AttemptsChart.vue"
import FlowchartScoreChart from "./components/FlowchartScoreChart.vue"
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
import Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import DurationChart from "./components/DurationChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { DURATION_OPTIONS } from "utils/constants" import UserName from "shared/components/UserName.vue"
import { durationFromValue } from "utils/functions" import { classLabel } from "oj/submission/utils"
import { parseTime } from "utils/functions"
import { getLearningReport } from "../api"
import AiSummary from "./components/AiSummary.vue"
import ErrorsCard from "./components/ErrorsCard.vue"
import KnowledgeCard from "./components/KnowledgeCard.vue"
import LessonDays from "./components/LessonDays.vue"
import StatStrip from "./components/StatStrip.vue"
import UnsolvedCard from "./components/UnsolvedCard.vue"
import { dayLabel, isDone } from "./utils"
const aiStore = useAIStore() const theme = useThemeVars()
const userStore = useUserStore() const userStore = useUserStore()
const options = [...DURATION_OPTIONS] const { isDesktop } = useBreakpoints()
const urlUsername = useRouteQuery<string>("username", "") const urlUsername = useRouteQuery<string>("username", "")
const urlDuration = useRouteQuery<string>("duration", "months:6") const urlPeriod = useRouteQuery<string>("period", "term")
const period = computed<LearningPeriod>(() => {
const parsed = learningPeriodSchema.safeParse(urlPeriod.value)
return parsed.success ? parsed.data : "term"
})
const PERIODS = Object.entries(LEARNING_PERIOD_LABELS) as [LearningPeriod, string][]
// Initialize store synchronously from URL params before watch fires /** 老师看别人。学生在地址栏写了别人的名字,后端也只会给他自己的 */
aiStore.targetUsername = urlUsername.value const viewing = computed(() =>
aiStore.duration = urlDuration.value userStore.isTeacherOrAbove && urlUsername.value && urlUsername.value !== userStore.user?.username
? urlUsername.value
const subOptions = computed<Duration>( : undefined,
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
) )
const start = computed(() => formatISO(sub(new Date(), subOptions.value))) const report = ref<LearningReport | null>(null)
const end = computed(() => formatISO(new Date())) const loading = ref(false)
const notFound = ref(false)
/** 这段时间一道没交时,再拿一份「全部」的,说说上一节课和还差的题 */
const before = ref<LearningReport | null>(null)
function onUsernameChange() { async function load() {
aiStore.targetUsername = urlUsername.value const want = { period: period.value, username: viewing.value }
aiStore.fetchHeatmapData() loading.value = true
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration) notFound.value = false
before.value = null
try {
const data = await getLearningReport(want.period, want.username)
if (want.period !== period.value || want.username !== viewing.value) return
report.value = data
if (!data.days.length && want.period !== "all")
before.value = await getLearningReport("all", want.username)
} catch {
report.value = null
notFound.value = true
} finally {
loading.value = false
}
} }
onMounted(() => { watch([period, viewing], load, { immediate: true })
aiStore.fetchHeatmapData()
const span = computed(() => {
const r = report.value
if (!r) return ""
if (!r.start) return ""
const from = parseTime(r.start, "M月D日")
const to = parseTime(r.end, "M月D日")
return from === to ? from : `${from} – ${to}`
}) })
watch( const emptyHint = computed(() => {
() => urlDuration.value, const last = before.value?.days.find((day) => day.lesson.length)
(val) => { if (!last) return ""
aiStore.duration = val const { date, weekday } = dayLabel(last.day)
aiStore.fetchAnalysisData(start.value, end.value, val) return `上一节课是 ${date} ${weekday},做对了 ${last.lesson.filter(isDone).length} / ${last.lesson.length} 道`
}, })
{ immediate: true }, const longer = computed(() => PERIODS.slice(PERIODS.findIndex(([key]) => key === period.value) + 1))
)
const searchName = ref("")
watch(viewing, (value) => (searchName.value = value ?? ""), { immediate: true })
function goStudent(username: string | null | undefined) {
if (username) urlUsername.value = username
}
</script> </script>
<template>
<div class="oj-page ai-page">
<div class="toolbar" :class="{ single: !isDesktop }">
<h2>智能分析</h2>
<div v-if="viewing" class="who">
<n-button
:disabled="!report?.neighbors?.prev"
aria-label="上一个学生"
@click="goStudent(report?.neighbors?.prev)"
>
‹
</n-button>
<n-input
v-model:value="searchName"
class="who-input"
placeholder="用户名"
@keyup.enter="goStudent(searchName.trim())"
/>
<n-button
:disabled="!report?.neighbors?.next"
aria-label="下一个学生"
@click="goStudent(report?.neighbors?.next)"
>
›
</n-button>
<span v-if="report?.className" class="muted">{{ classLabel(report.className) }}</span>
</div>
<div class="seg" role="group" aria-label="时间">
<button
v-for="[key, label] in PERIODS"
:key="key"
:class="{ on: period === key }"
@click="urlPeriod = key"
>
{{ label }}
</button>
</div>
<span v-if="isDesktop && span" class="muted">{{ span }}</span>
<span class="grow"></span>
<router-link
v-if="viewing"
:to="{ path: '/submission', query: { username: viewing, exactUsername: '1' } }"
class="link"
>
看<UserName :username="viewing" />的提交 ›
</router-link>
</div>
<n-spin :show="loading" :delay="150">
<n-empty v-if="notFound" description="没有这个用户" class="blank" />
<template v-else-if="report && !report.days.length">
<section class="card blank">
<b>{{ LEARNING_PERIOD_LABELS[period] }}{{ viewing ? "他" : "" }}还没交过题</b>
<span v-if="emptyHint" class="muted">{{ emptyHint }}</span>
<div class="actions">
<n-button v-for="[key, label] in longer" :key="key" @click="urlPeriod = key">
看{{ label }}
</n-button>
</div>
</section>
<section v-if="!viewing && before?.unsolved.length" class="card hint">
<b>还有 {{ before.unsolved.length }} 道差一点的</b>
<span class="muted names">{{
before.unsolved.map((item) => item.title).join("、")
}}</span>
<a href="#" class="link" @click.prevent="urlPeriod = 'all'">去看看 ›</a>
</section>
</template>
<div v-else-if="report" class="body">
<StatStrip :report="report" :teacher="!!viewing" :compact="!isDesktop" />
<div v-if="isDesktop" class="main">
<div class="column">
<LessonDays :days="report.days" :class-name="report.className" :username="viewing" />
<AiSummary :period="period" :username="viewing" />
</div>
<div class="column">
<UnsolvedCard
v-if="report.unsolved.length"
:items="report.unsolved"
:username="viewing"
/>
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
<KnowledgeCard :knowledge="report.knowledge" />
</div>
</div>
<template v-else>
<UnsolvedCard
v-if="report.unsolved.length"
:items="report.unsolved"
:username="viewing"
/>
<LessonDays
:days="report.days"
:class-name="report.className"
:username="viewing"
compact
/>
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
<KnowledgeCard :knowledge="report.knowledge" />
<AiSummary :period="period" :username="viewing" compact />
</template>
</div>
</n-spin>
</div>
</template>
<style scoped> <style scoped>
.title { .ai-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.toolbar h2 {
margin: 0; margin: 0;
font-size: var(--oj-fs-title); font-size: var(--oj-fs-title);
} }
.pair > :deep(.n-card) { .toolbar.single {
flex: 1 1 320px; flex-direction: column;
align-items: stretch;
gap: 8px;
}
.toolbar.single .seg button {
flex: 1;
}
.who {
display: flex;
align-items: center;
gap: 6px;
}
.who-input {
width: 160px;
}
.seg {
display: inline-flex;
height: var(--oj-ctrl-h);
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
overflow: hidden;
}
.seg button {
padding: 0 14px;
border: 0;
border-left: 1px solid v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor2");
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
white-space: nowrap;
}
.seg button:first-child {
border-left: 0;
}
.seg button.on {
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-weight: 600;
}
.grow {
flex: 1;
}
.muted {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
.link {
font-size: var(--oj-fs-sec);
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
.body,
.column {
display: flex;
flex-direction: column;
gap: 16px;
}
.main {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 16px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.blank {
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.blank b {
font-size: 18px;
}
.actions {
display: flex;
gap: 8px;
}
.hint {
margin-top: 16px;
padding: 12px 18px;
display: flex;
align-items: center;
gap: 10px;
border-color: rgba(240, 160, 32, 0.4);
background: rgba(240, 160, 32, 0.06);
font-size: var(--oj-fs-sec);
}
.names {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
</style> </style>
-88
View File
@@ -1,88 +0,0 @@
<template>
<n-card size="small">
<template #header>
<div class="cool-title">
<span class="title-text">AI 帮你分析</span>
</div>
</template>
<n-spin :show="aiStore.loading.ai" :delay="50">
<n-flex align="center" justify="center" class="container">
<n-button
v-if="!aiStore.mdContent && !aiStore.loading.ai"
type="primary"
size="large"
:loading="aiStore.loading.fetching"
@click="handleAnalyze"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
开始分析
</n-button>
<Markdown v-else :content="aiStore.mdContent" />
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import Markdown from "shared/components/Markdown.vue"
import { Icon } from "@iconify/vue"
const aiStore = useAIStore()
async function handleAnalyze() {
if (aiStore.loading.fetching || aiStore.loading.ai) {
return
}
if (aiStore.pinnedReport) {
await aiStore.simulatePinnedStream()
} else {
await aiStore.fetchAIAnalysis()
}
}
onMounted(async () => {
if (!aiStore.targetUsername) {
await aiStore.fetchPinnedReport()
}
})
</script>
<style scoped>
.cool-title {
position: relative;
padding: 8px 0;
}
.title-text {
font-size: 16px;
font-weight: 700;
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.8px;
position: relative;
z-index: 2;
animation: gradient-flow 3s ease infinite;
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 只按钮那一屏的时候别留一大片空白;出内容后高度由内容自己撑,
这里只是给加载中的 spin 一点地方 */
.container {
min-height: 120px;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { LearningPeriod } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import Markdown from "shared/components/Markdown.vue"
import { parseTime } from "utils/functions"
import { streamAI } from "utils/stream"
import { getLatestAnalysis } from "../../api"
/**
* AI 小结。进来先拿这个时间段上次生成的那份(老师钉住的优先),没有才出按钮 ——
* 一份小结要等十几秒、也要花钱,不在进页面时自动生成。
*/
const props = defineProps<{
period: LearningPeriod
username?: string
/** 手机:默认只露前几行 */
compact?: boolean
}>()
const theme = useThemeVars()
const content = ref("")
const createdAt = ref("")
const pinned = ref(false)
const generating = ref(false)
const waiting = ref(false)
const failed = ref("")
const expanded = ref(false)
let controller: AbortController | null = null
async function load() {
controller?.abort()
generating.value = false
failed.value = ""
expanded.value = false
const period = props.period
const username = props.username
const record = await getLatestAnalysis(period, username).catch(() => null)
if (period !== props.period || username !== props.username) return
content.value = record?.analysis ?? ""
createdAt.value = record?.createTime ?? ""
pinned.value = record?.isPinned ?? false
}
watch(() => [props.period, props.username], load, { immediate: true })
onBeforeUnmount(() => controller?.abort())
async function generate() {
controller?.abort()
const current = new AbortController()
controller = current
generating.value = true
waiting.value = true
failed.value = ""
content.value = ""
pinned.value = false
expanded.value = true
try {
await streamAI(
"ai/analysis",
{ period: props.period, username: props.username || undefined },
{
signal: current.signal,
onDelta(delta) {
waiting.value = false
content.value += delta
},
},
)
createdAt.value = new Date().toISOString()
} catch (error) {
if (current.signal.aborted) return
failed.value = (error as Error).message || "AI 服务暂时不可用,请稍后再试"
} finally {
if (controller === current) {
generating.value = false
waiting.value = false
}
}
}
const folded = computed(() => props.compact && !expanded.value && !generating.value)
</script>
<template>
<section class="card">
<h3><Icon icon="ph:sparkle" />AI 帮你分析</h3>
<template v-if="content">
<div class="body" :class="{ folded }">
<Markdown :content="content" />
</div>
<div v-if="!generating" class="foot">
<a v-if="folded" href="#" @click.prevent="expanded = true">展开全文 ▾</a>
<span v-if="createdAt"
>{{ parseTime(createdAt, "M月D日") }} 生成{{ pinned ? " · 老师置顶" : "" }}</span
>
<a href="#" @click.prevent="generate">重新分析</a>
</div>
</template>
<n-spin v-else-if="waiting" size="small">
<div class="waiting">正在看{{ username ? "他" : "你" }}的做题记录…</div>
</n-spin>
<div v-else class="empty">
<p>让 AI 按这些数据,写一段{{ username ? "给他" : "给你" }}的小结和下一步建议</p>
<n-button type="primary" @click="generate">
<template #icon><Icon icon="ph:sparkle" /></template>
AI 帮我分析
</n-button>
</div>
<p v-if="failed" class="failed">{{ failed }}</p>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(107, 71, 194, 0.3);
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--oj-fs-h2);
color: #7c5cd0;
}
.body.folded {
max-height: 150px;
overflow: hidden;
/* Chrome 105 只认带前缀的,不带前缀的到 120 才有 */
-webkit-mask-image: linear-gradient(#000 60%, transparent);
mask-image: linear-gradient(#000 60%, transparent);
}
.foot {
display: flex;
gap: 12px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.foot a {
color: #18a058;
text-decoration: none;
}
.waiting {
height: 60px;
}
.empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.empty p,
.failed {
margin: 0;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
}
.failed {
color: v-bind("theme.errorColor");
}
</style>
@@ -1,89 +0,0 @@
<template>
<n-card title="几次做对" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 通过前提交了几次,看有没有在死磕 </n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// detailsData.attempts 是后端算的「每道题到首次通过为止的提交次数」,分档放在前端,
// 想调档位不用改契约。逐题的题名不在这里 —— 明细是分页拿的,别为了 tooltip 把全量拉回来
const BUCKETS = [
{ label: "一次过", color: "#18A058", min: 1, max: 1 },
{ label: "2-3 次", color: "#2080F0", min: 2, max: 3 },
{ label: "4-6 次", color: "#F0A020", min: 4, max: 6 },
{ label: "7 次以上", color: "#D03050", min: 7, max: Infinity },
]
const buckets = computed(() =>
BUCKETS.map((bucket) => ({
...bucket,
count: aiStore.detailsData.attempts.filter(
(value) => value >= bucket.min && value <= bucket.max,
).length,
})),
)
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.count),
backgroundColor: buckets.value.map((bucket) => bucket.color),
borderColor: buckets.value.map((bucket) => bucket.color),
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 64,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
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)}%)`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -1,72 +0,0 @@
<template>
<n-card title="难度分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 看看简单题和难题各做了多少 </n-text>
</template>
<div style="height: 300px">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 难度和等级的顺序(后端返回的是中文)
const difficultyOrder = ["简单", "中等", "困难"]
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有。
// 直接读后端的 difficulty 聚合 —— 逐题列表现在是分页拿的,前端手上没有全量
const counts = computed(() =>
difficultyOrder.map((name) => aiStore.detailsData.difficulty[name] ?? 0),
)
const show = computed(() => aiStore.detailsData.solvedCount > 0)
const data = computed(() => ({
labels: difficultyOrder,
datasets: [
{
label: "完成题目数",
data: counts.value,
backgroundColor: difficultyColors,
borderColor: difficultyColors,
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 64,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `完成 ${ctx.parsed.y} 道题`,
},
},
},
}))
</script>
@@ -1,171 +0,0 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 做题量和提交质量的变化 </n-text>
</template>
<div class="chart">
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip,
Legend,
Colors,
LineController,
BarController,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
import { parseTime } from "utils/functions"
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip,
Legend,
Colors,
LineController,
BarController,
)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周情况"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月情况"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月情况"
} else {
return "过去四周的情况"
}
})
const show = computed(() => aiStore.durationData.length > 0)
// 这一张顶掉了原来的三张(进步曲线 / 提交效率 / 周期综合)—— 它们读的是同一个
// durationData,半年视图统共就 6 个桶、四个字段,没有必要摊成三张卡六条曲线。
// 等级放进 tooltip 不再单独占一条轴:S/A/B/C 是四档离散值,连成折线读不出东西,
// 而且逐题的等级在下面的解题表格里本来就有
const data = computed<ChartData<"bar" | "line">>(() => ({
labels: aiStore.durationData.map((duration) =>
[parseTime(duration.start, "M月D日"), parseTime(duration.end, "M月D日")].join("~"),
),
datasets: [
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problemCount),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submissionCount),
yAxisID: "y",
order: 2,
},
{
type: "line",
label: "AC率",
// 没有提交的周期给 null 而不是 0,配合 spanGaps: false 断开,
// 否则空档会被画成「AC率 0%」,看着像交了一堆全错
data: aiStore.durationData.map((duration) =>
duration.submissionCount > 0
? (duration.acceptedCount / duration.submissionCount) * 100
: null,
),
spanGaps: false,
tension: 0.4,
yAxisID: "y1",
order: 1,
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}))
const options = computed<ChartOptions<"bar" | "line">>(() => ({
interaction: {
intersect: false,
mode: "index",
},
maintainAspectRatio: false,
scales: {
x: {
grid: { display: false },
},
y: {
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "数量" },
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: 0,
max: 100,
ticks: { callback: (v) => `${Number(v).toFixed(0)}%` },
title: { display: true, text: "AC率" },
grid: { display: false },
},
},
plugins: {
legend: {
display: true,
position: "bottom",
labels: {
boxWidth: 12,
padding: 8,
font: { size: 11 },
},
},
title: { display: false },
tooltip: {
callbacks: {
label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || ""
if (ctx.dataset.label === "AC率") {
return `${dsLabel}: ${Number(ctx.parsed.y).toFixed(1)}%`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
footer: (items: TooltipItem<"bar" | "line">[]) => {
const index = items[0]?.dataIndex
const bucket = index === undefined ? undefined : aiStore.durationData[index]
if (!bucket) return ""
const lines = [`本期等级: ${bucket.grade || "无"}`]
if (bucket.submissionCount > 0) {
lines.push(`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`)
}
return lines
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 320px;
width: 100%;
}
</style>
@@ -1,105 +0,0 @@
<template>
<n-card title="错在哪里" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">没通过的提交都是什么错</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 判题状态码是落库的值,名字统一从 JUDGE_STATUS 取,不在这里另抄一份中文
const COLORS: Record<string, string> = {
"-2": "#F0A020", // 编译失败
"-1": "#D03050", // 答案错误
"1": "#7C4DFF", // 运行超时
"2": "#7C4DFF",
"3": "#2080F0", // 内存超限
"4": "#E88080", // 运行错误
"5": "#909399", // 系统错误
"8": "#18A058", // 部分正确
}
// 同一个中文名可能对应多个状态码(1 和 2 都叫「运行超时」),按名字合并
const grouped = computed(() => {
const byName = new Map<string, { count: number; color: string }>()
for (const item of aiStore.detailsData.errors) {
const name =
JUDGE_STATUS[String(item.result) as unknown as SUBMISSION_RESULT]?.name ??
`状态 ${item.result}`
const seen = byName.get(name)
if (seen) seen.count += item.count
else
byName.set(name, {
count: item.count,
color: COLORS[String(item.result)] ?? "#909399",
})
}
return [...byName].sort((a, b) => b[1].count - a[1].count)
})
const show = computed(() => grouped.value.length > 0)
const data = computed(() => ({
labels: grouped.value.map(([name]) => name),
datasets: [
{
label: "提交次数",
data: grouped.value.map(([, item]) => item.count),
backgroundColor: grouped.value.map(([, item]) => item.color),
borderColor: grouped.value.map(([, item]) => item.color),
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 28,
},
],
}))
const total = computed(() => grouped.value.reduce((sum, [, item]) => sum + item.count, 0))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "提交次数" },
},
y: { grid: { display: false } },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const value = Number(ctx.parsed.x)
const percent = total.value ? (value / total.value) * 100 : 0
return `${value} 次(占没通过的 ${percent.toFixed(0)}%)`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/** 没通过的提交按结果分;错得最多的那种下面给一个检查办法 */
const props = defineProps<{ errors: LearningReport["errors"] }>()
const theme = useThemeVars()
/** 两个超时码合成一行 */
const rows = computed(() => {
const merged = new Map<string, number>()
for (const item of props.errors) {
const name = statusName(item.result) || "其他"
merged.set(name, (merged.get(name) ?? 0) + item.count)
}
return [...merged].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }))
})
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0))
const top = computed(() => rows.value[0]?.count ?? 1)
const COLORS: Record<string, string> = { 答案错误: "#d03050", 编译失败: "#e7a33e" }
const TIPS: Record<string, string> = {
答案错误: "先拿题目里的例子自己跑一遍,输出要和例子一字不差",
编译失败: "看报错指的是第几行,多半是括号、冒号、引号没配对",
运行时错误: "看报错说的是第几行、什么错,再回到那一行",
运行超时: "看看循环能不能停下来",
}
const tip = computed(() => TIPS[rows.value[0]?.name ?? ""])
</script>
<template>
<section class="card">
<h3>
错在哪<small>没通过的 {{ total }} 次</small>
</h3>
<div v-for="row in rows" :key="row.name" class="row">
<span class="name">{{ row.name }}</span>
<span class="track">
<span
class="bar"
:style="{
width: `${(row.count / top) * 100}%`,
background: COLORS[row.name] ?? '#9aa3ad',
}"
></span>
</span>
<span class="count">{{ row.count }} 次</span>
</div>
<div v-if="tip" class="tip">{{ tip }}</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 8px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.row {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--oj-fs-sec);
}
.name {
width: 74px;
flex-shrink: 0;
}
.track {
flex: 1;
height: 10px;
border-radius: 2px 5px 5px 2px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.bar {
display: block;
height: 100%;
min-width: 3px;
border-radius: 2px 5px 5px 2px;
}
.count {
width: 52px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.tip {
padding: 8px 10px;
border-radius: 6px;
background: v-bind("theme.actionColor");
font-size: var(--oj-fs-sec);
line-height: 22px;
}
</style>
@@ -1,92 +0,0 @@
<template>
<n-card title="流程图得分" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">每题的最高分和平均分</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// detailsData.flowcharts 早就在下发了,但全页一张图都没有,只在解题表格的
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
const items = computed(() =>
[...aiStore.detailsData.flowcharts].sort(
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
),
)
const show = computed(() => items.value.length > 0)
const data = computed(() => ({
labels: items.value.map((item) => `${item.problemId} ${item.problemTitle}`),
datasets: [
{
label: "最高分",
data: items.value.map((item) => item.bestScore),
backgroundColor: "#18A058",
borderColor: "#18A058",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 22,
},
{
label: "平均分",
data: items.value.map((item) => item.avgScore),
backgroundColor: "rgba(99, 102, 241, 0.75)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 22,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
max: 100,
title: { display: true, text: "得分" },
},
y: { grid: { display: false } },
},
plugins: {
legend: {
display: true,
position: "bottom",
labels: { boxWidth: 12, padding: 8, font: { size: 11 } },
},
tooltip: {
callbacks: {
afterBody: (ctx) => {
const item = items.value[ctx[0]!.dataIndex]
if (!item) return ""
return `提交 ${item.submissionCount} 次${item.bestGrade ? `,最好等级 ${item.bestGrade}` : ""}`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
-55
View File
@@ -1,55 +0,0 @@
<template>
<div align="center" style="display: inline-flex; margin: 0 10px">
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16" style="cursor: help">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
/>
</svg>
</n-icon>
</template>
<div style="max-width: 300px; line-height: 1.4">
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
<div>使用加权平均方法计算综合等级:</div>
<div>• S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分</div>
<div>• 根据平均分数确定最终等级:</div>
<div>- S级:≥3.5分</div>
<div>- A级:2.5-3.5分</div>
<div>- B级:1.5-2.5分</div>
<div>- C级:<1.5分</div>
</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
const props = defineProps<{
// 空串是「无评级」,四张图都不渲染 —— 后端没有可用数据时会下发它
grade: Grade
}>()
</script>
<style scoped>
img {
animation: shake 0.5s infinite;
width: 30px;
height: 30px;
}
@keyframes shake {
0% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-10px) scale(1.1);
}
100% {
transform: translateY(0) scale(1);
}
}
</style>
-258
View File
@@ -1,258 +0,0 @@
<template>
<n-card title="过去一年的提交热力图" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 每格一周,激励持续学习 </n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<div
class="heatmap-container"
ref="containerRef"
:style="{
'--cell-stroke': cellStroke,
'--cell-stroke-hover': cellStrokeHover,
}"
>
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
preserveAspectRatio="xMinYMin meet"
class="heatmap-svg"
>
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
<text :x="label.x" :y="10" class="label" font-size="10">
{{ label.text }}
</text>
</g>
<g :transform="`translate(0, ${MONTH_HEIGHT})`">
<rect
v-for="(cell, i) in cells"
:key="i"
:x="cell.x"
:y="0"
:width="CELL_SIZE"
:height="CELL_HEIGHT"
:fill="cell.color"
class="cell"
rx="3"
@mouseenter="(e) => showTooltip(e, cell)"
@mouseleave="hideTooltip"
/>
</g>
</svg>
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
<div class="tooltip-date">{{ tooltip.date }}</div>
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
{{ tooltip.text }}
</div>
</div>
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime, zonedParts } from "utils/functions"
import { useChartTheme } from "shared/composables/chartTheme"
const aiStore = useAIStore()
const { isDark } = useChartTheme()
const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 22
// 一格一周之后只剩一行,格子做成竖长条,卡片不至于扁成一条缝
const CELL_HEIGHT = 34
const CELL_GAP = 4
const CELL_TOTAL = CELL_SIZE + CELL_GAP
const MONTH_HEIGHT = 18
const RIGHT_PADDING = 5
// 深色下空格子不能再用接近白的 #ebedf0,整张图会变成一片发亮的方块
const LIGHT_COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
const DARK_COLORS = ["#22272e", "#0e4429", "#006d32", "#26a641", "#39d353"]
const COLORS = computed(() => (isDark.value ? DARK_COLORS : LIGHT_COLORS))
const cellStroke = computed(() =>
isDark.value ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.05)",
)
const cellStrokeHover = computed(() =>
isDark.value ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.3)",
)
// 阈值按「一周」定,不是按一天。按天的老阈值(>7 就到顶)放到周上,
// 稍微认真做几天题就全是最深的一档,看不出差别
const getColor = (count: number) => {
const palette = COLORS.value
if (count === 0) return palette[0]
if (count <= 3) return palette[1]
if (count <= 8) return palette[2]
if (count <= 15) return palette[3]
return palette[4]
}
// 一格一周,横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
// 365 格里三百多格空着,整张图看着像没用过。
//
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,年月日按东八区取(`zonedParts`),
// 不用浏览器本地的 `getMonth()` / `getDate()`。东八区没有夏令时,周日就是加 6 天。
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => {
const start = new Date(item.timestamp)
const parts = zonedParts(start)
return {
start,
end: new Date(start.getTime() + 6 * 86_400_000),
month: parts?.month ?? 1,
day: parts?.day ?? 1,
count: item.value,
color: getColor(item.value),
x: i * CELL_TOTAL,
}
}),
)
const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.month
if (month !== lastMonth) {
// 第一格所在的月往往只露出小半个月,标签会和下一个月挤在一起,跳过
if (i > 0 || cell.day <= 7) {
labels.push({ text: `${month}月`, x: cell.x })
}
lastMonth = month
}
})
return labels
})
const svgWidth = computed(() => cells.value.length * CELL_TOTAL + RIGHT_PADDING)
const svgHeight = computed(() => MONTH_HEIGHT + CELL_HEIGHT)
interface Cell {
start: Date
end: Date
count: number
color: string
x: number
}
const tooltip = ref<{
x: number
y: number
date: string
text: string
count: number
} | null>(null)
const tooltipStyle = computed(() => ({
left: `${tooltip.value?.x}px`,
top: `${tooltip.value?.y}px`,
}))
const getTooltipText = (count: number) => (count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`)
const showTooltip = (e: MouseEvent, cell: Cell) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (containerRect) {
tooltip.value = {
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 10,
date: `${parseTime(cell.start, "M月D日")} ~ ${parseTime(cell.end, "M月D日")}`,
text: getTooltipText(cell.count),
count: cell.count,
}
}
}
const hideTooltip = () => {
tooltip.value = null
}
</script>
<style scoped>
.heatmap-container {
width: 100%;
padding: 10px 0;
position: relative;
}
.heatmap-svg {
width: 100%;
height: auto;
display: block;
}
.label {
fill: currentColor;
opacity: 0.7;
}
.cell {
cursor: pointer;
transition: all 0.2s ease;
stroke: var(--cell-stroke);
stroke-width: 0.5;
}
.cell:hover {
stroke: var(--cell-stroke-hover);
stroke-width: 1.5;
filter: brightness(0.9);
}
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.2s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-date {
font-weight: 500;
margin-bottom: 2px;
}
.tooltip-count {
opacity: 0.6;
}
.tooltip-count.active {
color: #7bc96f;
opacity: 0.9;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 5px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
/** 全部知识点:做对过的标绿写几道,做过没做对的写「还没做对」,没碰过的虚线。点进题目列表按它筛 */
const props = defineProps<{ knowledge: LearningReport["knowledge"] }>()
const theme = useThemeVars()
const touched = computed(() => props.knowledge.filter((item) => item.tried).length)
</script>
<template>
<section class="card">
<h3>
知识点<small>做过 {{ touched }} / {{ knowledge.length }} 个</small>
</h3>
<div class="grid">
<router-link
v-for="item in knowledge"
:key="item.name"
:to="{ path: '/problem', query: { tag: item.name } }"
class="cell"
:class="item.solved ? 'solved' : item.tried ? 'tried' : 'untouched'"
>
<span class="name">{{ item.name }}</span>
<span class="sub">
{{ item.solved ? `做对 ${item.solved} 道` : item.tried ? "还没做对" : "还没做过" }}
</span>
</router-link>
</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 8px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.cell {
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 6px;
text-decoration: none;
color: v-bind("theme.textColor1");
min-width: 0;
}
.name {
font-size: var(--oj-fs-sec);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12px;
}
.solved {
background: rgba(24, 160, 88, 0.12);
}
.solved .name {
font-weight: 600;
}
.solved .sub {
color: #18a058;
}
.tried {
background: rgba(240, 160, 32, 0.12);
}
.tried .sub {
color: #b86e00;
}
.untouched {
border: 1px dashed v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { LearningDay } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import ProblemChip from "./ProblemChip.vue"
import { dayLabel, isDone } from "../utils"
/** 一天一行,新的在前:先是班上这节课的题,再是这天自己练的 */
const props = defineProps<{
days: LearningDay[]
className: string | null
username?: string
/** 手机:日期单独一行,题目块在下面折行 */
compact?: boolean
}>()
const theme = useThemeVars()
/** 一天自己练的超过这么多就先收起来;做得多的学生一天能自己刷二十几道 */
const OWN_CAP = 10
/** 「全部」能有上百天,先列这么多天 */
const DAY_CAP = 12
const openOwn = ref(new Set<string>())
const showAll = ref(false)
watch(
() => props.days,
() => {
openOwn.value = new Set()
showAll.value = false
},
)
const shown = computed(() => (showAll.value ? props.days : props.days.slice(0, DAY_CAP)))
const you = computed(() => (props.username ? "他" : "你"))
const subtitle = computed(() =>
props.className
? `${classLabel(props.className)}课上做的题,加上${you.value}自己练的`
: `${you.value}做过的题`,
)
function ownOf(day: LearningDay) {
return openOwn.value.has(day.day) ? day.own : day.own.slice(0, OWN_CAP)
}
function expand(day: string) {
openOwn.value = new Set([...openOwn.value, day])
}
</script>
<template>
<section class="card" :class="{ compact }">
<h3>
每节课<small>{{ subtitle }}</small>
</h3>
<div v-for="day in shown" :key="day.day" class="day">
<div class="when">
<b>{{ dayLabel(day.day).date }}</b>
<span>{{ dayLabel(day.day).weekday }}</span>
<span v-if="compact && day.lesson.length" class="done">
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
</span>
</div>
<div class="groups">
<div v-if="day.lesson.length" class="chips">
<ProblemChip
v-for="item in day.lesson"
:key="item.displayId"
:item="item"
in-lesson
:username="username"
/>
</div>
<div v-if="day.own.length" class="chips">
<span class="own">自己练</span>
<ProblemChip
v-for="item in ownOf(day)"
:key="item.displayId"
:item="item"
:in-lesson="false"
:username="username"
/>
<button
v-if="day.own.length > OWN_CAP && !openOwn.has(day.day)"
class="more"
@click="expand(day.day)"
>
还有 {{ day.own.length - OWN_CAP }} 道 ▾
</button>
</div>
</div>
<div v-if="!compact" class="done">
<template v-if="day.lesson.length">
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
</template>
</div>
</div>
<button v-if="!showAll && days.length > DAY_CAP" class="earlier" @click="showAll = true">
更早的 {{ days.length - DAY_CAP }} 天 ▾
</button>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 6px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0 0 8px;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 10px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.day {
display: flex;
gap: 12px;
padding: 10px 0;
border-top: 1px solid v-bind("theme.dividerColor");
}
.when {
width: 92px;
flex-shrink: 0;
line-height: 30px;
white-space: nowrap;
}
.when b {
font-size: var(--oj-fs-body);
margin-right: 4px;
}
.when span {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.groups {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.own {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
margin-right: 2px;
}
.done {
width: 72px;
flex-shrink: 0;
text-align: right;
line-height: 30px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.more,
.earlier {
height: 30px;
padding: 0 12px;
border: 1px dashed v-bind("theme.borderColor");
border-radius: 6px;
background: transparent;
color: #18a058;
font: inherit;
font-size: var(--oj-fs-meta);
cursor: pointer;
}
.earlier {
width: 100%;
margin: 4px 0 10px;
}
.compact {
padding: 12px 14px 4px;
}
.compact .day {
flex-direction: column;
gap: 6px;
}
.compact .when {
width: auto;
display: flex;
align-items: baseline;
gap: 6px;
line-height: 22px;
}
.compact .when .done {
margin-left: auto;
width: auto;
line-height: 22px;
}
</style>
@@ -1,53 +0,0 @@
<template>
<n-alert :show-icon="false" type="success" v-if="aiStore.detailsData.solvedCount">
<span>{{ durationLabel }},</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contestCount > 0">
,并且参加
<b class="charming"> {{ aiStore.detailsData.contestCount }} </b>
次比赛
</span>
<span>,综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-alert v-else type="error" title="你还没有完成任何题目">
开始解题,看看你的学习能力吧!
</n-alert>
</template>
<script lang="ts" setup>
import Grade from "./Grade.vue"
import { parseTime } from "utils/functions"
import { useAIStore } from "oj/store/ai"
const aiStore = useAIStore()
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
} else if (aiStore.duration.includes("days")) {
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
} else if (aiStore.duration.includes("weeks") || aiStore.duration.includes("months")) {
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
} else {
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
}
})
const greeting = computed(() => {
return {
"": "还没有足够的数据来评级",
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { LearningItem } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/**
* 一道题一块:绿勾一次就对、黄色交几次才对、红色没做对、虚线没交。
* 指上去看细节(班上第几个做对、最后一次错在哪),点进去学生是做题、老师是看提交。
*/
const props = defineProps<{
item: LearningItem
/** 课上的题才说「班上第几个做对」;自己练的题同学大多没做,排第几没意义 */
inLesson: boolean
/** 老师在看这个学生:称呼用「他」,点进去看他的提交 */
username?: string
}>()
const theme = useThemeVars()
const you = computed(() => (props.username ? "他" : "你"))
const problemLink = computed(() => `/problem/${props.item.displayId}`)
const submissionLink = computed(() => ({
path: "/submission",
query: props.username
? { problem: props.item.displayId, username: props.username, exactUsername: "1" }
: { problem: props.item.displayId, myself: "1" },
}))
const detail = computed(() => {
const item = props.item
const rank =
props.inLesson && item.rank && item.classSolved
? `班上第 ${item.rank} 个做对(${item.classSolved} 人做对)`
: ""
if (item.status === "first") return ["一次就对", rank]
if (item.status === "tries") return [`交了 ${item.attempts} 次才对`, rank]
if (item.status === "fail") {
const last = statusName(item.lastResult)
return [`交了 ${item.attempts} 次还没做对`, last ? `最后一次${last}` : ""]
}
return [
`这节课的题,${you.value}还没交`,
item.classSolved ? `班上 ${item.classSolved} 人做对` : "",
]
})
</script>
<template>
<n-popover trigger="hover" :delay="200" :show-arrow="false" placement="bottom-start">
<template #trigger>
<router-link :to="username ? submissionLink : problemLink" class="chip" :class="item.status">
<Icon
v-if="item.status === 'first'"
icon="ph:check-bold"
class="check"
aria-label="一次就对"
/>
<span class="title">{{ item.title }}</span>
<span v-if="item.status === 'tries'" class="count">交 {{ item.attempts }} 次</span>
<span v-else-if="item.status === 'fail'" class="count"
>没做对 · {{ item.attempts }} 次</span
>
<span v-else-if="item.status === 'none'" class="count">没交</span>
</router-link>
</template>
<div class="tip">
<span>{{ detail.filter(Boolean).join(" · ") }}</span>
<router-link v-if="item.status !== 'none'" :to="submissionLink"
>看{{ you }}的提交 ›</router-link
>
<router-link v-else-if="!username" :to="problemLink">去做 ›</router-link>
</div>
</n-popover>
</template>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
height: 30px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor1");
text-decoration: none;
white-space: nowrap;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
}
.count {
flex-shrink: 0;
font-size: var(--oj-fs-meta);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.check {
flex-shrink: 0;
color: #18a058;
}
.first {
background: rgba(24, 160, 88, 0.12);
}
.tries {
background: rgba(240, 160, 32, 0.15);
}
.tries .count {
color: #b86e00;
}
.fail {
background: rgba(208, 48, 80, 0.09);
border-color: rgba(208, 48, 80, 0.3);
}
.fail .count {
color: #c0283f;
}
.none {
border: 1px dashed v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
}
.chip:hover {
filter: brightness(0.97);
}
.tip {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--oj-fs-meta);
}
.tip a {
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
</style>
@@ -1,166 +0,0 @@
<template>
<n-tabs animated v-if="hasSolved && flowcharts.length">
<n-tab-pane name="代码提交">
<n-data-table
remote
striped
:data="aiStore.solvedRows"
:columns="columns"
:loading="aiStore.loading.solved"
:pagination="solvedPagination"
@update:page="aiStore.fetchSolved"
/>
</n-tab-pane>
<n-tab-pane name="流程图提交">
<n-data-table
striped
:data="flowcharts"
:columns="flowchartsColumns"
:pagination="paginationFor(flowcharts)"
/>
</n-tab-pane>
</n-tabs>
<n-data-table
v-else-if="hasSolved"
remote
striped
:data="aiStore.solvedRows"
:columns="columns"
:loading="aiStore.loading.solved"
:pagination="solvedPagination"
@update:page="aiStore.fetchSolved"
/>
<n-data-table
v-else-if="flowcharts.length"
striped
:data="flowcharts"
:columns="flowchartsColumns"
:pagination="paginationFor(flowcharts)"
/>
</template>
<script lang="ts" setup>
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 { parseTime } from "utils/functions"
const router = useRouter()
const aiStore = useAIStore()
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<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contestId) {
router.push("/contest/" + row.problem.contestId + "/problem/" + row.problem.displayId)
} else {
router.push("/problem/" + row.problem.displayId)
}
},
},
() => {
if (row.problem.contestId) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.displayId + " " + row.problem.title
}
},
),
},
{
// 用后端下发的 rankScope,不要看 className 有没有值:班里只有一个人时
// 后端会回退到全服排名,那种学生原来看到的是「班级排名」配全服数据
title: () => (aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.acCount,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.periodRank + " / " + row.periodAcCount,
},
{
title: () =>
h(NTooltip, null, {
trigger: () => h("span", { style: "cursor:help; border-bottom: 1px dashed" }, "等级"),
default: () =>
h("div", null, [
h("div", null, "基于同时段排名的百分位:"),
h("div", null, "S — 前 10%"),
h("div", null, "A — 前 35%"),
h("div", null, "B — 前 75%"),
h("div", null, "C — 其余"),
]),
}),
key: "grade",
width: 100,
align: "center",
},
]
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
title: "完成的题目",
key: "problem_title",
width: 300,
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
router.push("/problem/" + row.problemId)
},
},
() => `${row.problemId} ${row.problemTitle}`,
),
},
{ title: "提交次数", key: "submissionCount", width: 100, align: "center" },
{
title: "最高分",
key: "best",
width: 100,
align: "center",
render: (row) => `${row.bestScore} (${row.bestGrade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latestSubmissionTime),
},
{ title: "平均分", key: "avgScore", width: 100, align: "center" },
]
</script>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { LearningReport } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
/** 顶上一排数:做对几道、一次就对、上了几节课、差一点的;老师看时最前面多一格「班上第几」 */
const props = defineProps<{ report: LearningReport; teacher: boolean; compact?: boolean }>()
const theme = useThemeVars()
interface Cell {
big: number
unit: string
sub: string
warn?: boolean
}
const cells = computed(() => {
const r = props.report
const c = r.classStats
const percent = r.solved ? Math.round((r.firstTry / r.solved) * 100) : 0
const firstSub = `一次就对 · 占 ${percent}%`
const list: Cell[] = [
{ big: r.solved, unit: "道", sub: c ? `做对 · 班上中间那位 ${c.medianSolved} 道` : "做对" },
{
big: r.firstTry,
unit: "道",
sub: c && !props.compact ? `${firstSub},班上中间那位 ${c.medianFirstPercent}%` : firstSub,
},
{ big: r.lessonCount, unit: "节课", sub: `交了 ${r.submissionCount} 次` },
]
if (r.unsolved.length)
list.push({ big: r.unsolved.length, unit: "道", sub: "差一点 · 交过还没做对", warn: true })
if (props.teacher && c)
list.unshift({ big: c.rank, unit: `/ ${c.size}`, sub: "班上按做对题数排第几" })
return list
})
</script>
<template>
<div class="strip" :class="{ compact }">
<div v-for="cell in cells" :key="cell.sub" class="cell">
<div class="num">
<b :class="{ warn: cell.warn }">{{ cell.big }}</b>
<span>{{ cell.unit }}</span>
</div>
<div class="sub">{{ cell.sub }}</div>
</div>
</div>
</template>
<style scoped>
.strip {
display: flex;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.cell {
flex: 1;
min-width: 0;
padding: 14px 20px;
border-left: 1px solid v-bind("theme.dividerColor");
}
.cell:first-child {
border-left: 0;
}
.num {
display: flex;
align-items: baseline;
gap: 4px;
}
.num b {
font-size: 30px;
line-height: 38px;
font-variant-numeric: tabular-nums;
}
.num b.warn {
color: #c27c0e;
}
.num span {
font-size: var(--oj-fs-body);
}
.sub {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.compact {
display: grid;
grid-template-columns: 1fr 1fr;
}
.compact .cell {
padding: 10px 12px;
border-left: 0;
}
.compact .num b {
font-size: 26px;
line-height: 32px;
}
</style>
@@ -1,17 +0,0 @@
<template>
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contestTitle }}</span>
</n-flex>
<span>{{ problem.displayId }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
import type { SolvedProblem } from "utils/types"
interface Props {
problem: SolvedProblem["problem"]
}
const props = defineProps<Props>()
</script>
@@ -1,76 +0,0 @@
<template>
<n-card title="知识点分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">看看做过哪几类题</n-text>
</template>
<div class="chart">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartOptions } from "chart.js"
import { Bar } from "vue-chartjs"
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
// 横向条形而不是雷达图:只有 3~5 个类目,雷达对「比较大小」是最差的形式之一,
// 而且原来还把值归一化成「占最多标签的百分比」,第一名恒为 100%,等于只画了个排序。
// 这里直接画真实题数。后端 topTags 已经截到前 5,不需要再截
const entries = computed(() =>
Object.entries(aiStore.detailsData.tags).sort(([, a], [, b]) => b - a),
)
const show = computed(() => entries.value.length > 0)
const data = computed(() => ({
labels: entries.value.map(([label]) => label),
datasets: [
{
label: "完成题目数",
data: entries.value.map(([, value]) => value),
backgroundColor: "rgba(99, 102, 241, 0.75)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 28,
},
],
}))
const options = computed<ChartOptions<"bar">>(() => ({
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: { stepSize: 1, precision: 0 },
title: { display: true, text: "题目数量" },
},
y: {
grid: { display: false },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `完成 ${ctx.parsed.x} 道题`,
},
},
},
}))
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>
@@ -1,138 +0,0 @@
<template>
<n-card title="时间活跃度分析" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 基于全部提交,发现做题高峰时段 </n-text>
</template>
<div style="height: 300px">
<Bar :key="chartKey" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
const { chartKey } = useChartTheme()
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const TIME_PERIODS = [
{ label: "凌晨(0-6)", start: 0, end: 6 },
{ label: "上午(6-12)", start: 6, end: 12 },
{ label: "下午(12-18)", start: 12, end: 18 },
{ label: "晚上(18-24)", start: 18, end: 24 },
]
// 直接用后端聚合好的 activity(按全部提交、按东八区切好的 7×4)。
// 原来是拿 solved 里的 acTime 在浏览器本地时区现算 —— 只统计 AC,
// 28 个格子里能落进去的点太少;时区也和后端的热力图对不上
const activityMatrix = computed(() => {
const matrix: { [weekday: number]: { [period: number]: number } } = {}
for (let i = 0; i < 7; i++) {
matrix[i] = {}
for (let j = 0; j < TIME_PERIODS.length; j++) {
matrix[i][j] = 0
}
}
aiStore.detailsData.activity.forEach((item) => {
const row = matrix[item.weekday]
if (row && item.period in row) row[item.period] += item.count
})
return matrix
})
const show = computed(() => aiStore.detailsData.activity.some((item) => item.count > 0))
// 为每个时间段准备数据集
const data = computed(() => {
const datasets = TIME_PERIODS.map((period, periodIndex) => {
return {
label: period.label,
data: WEEKDAYS.map((_, weekday) => activityMatrix.value[weekday][periodIndex]),
backgroundColor: getTimePeriodColor(periodIndex),
borderColor: getTimePeriodColor(periodIndex),
borderWidth: 1,
}
})
return {
labels: WEEKDAYS,
datasets,
}
})
// 根据时间段返回对应的颜色
function getTimePeriodColor(periodIndex: number): string {
const colors = [
"#9D9D9D", // 凌晨 - 灰色
"#FFD700", // 上午 - 金色
"#4ECDC4", // 下午 - 青色
"#5B5F97", // 晚上 - 深蓝紫
]
return colors[periodIndex] || "#999"
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "提交次数",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `当天总计: ${total} 次提交`
},
},
},
},
}
</script>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { LearningItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { statusName } from "../utils"
/** 交过还没做对的题。学生「接着做」,老师「看提交」 */
const props = defineProps<{ items: LearningItem[]; username?: string }>()
const theme = useThemeVars()
const router = useRouter()
function submissions(item: LearningItem) {
return {
path: "/submission",
query: { problem: item.displayId, username: props.username, exactUsername: "1" },
}
}
</script>
<template>
<section class="card">
<h3>差一点的</h3>
<div v-for="item in items" :key="item.displayId" class="row">
<div class="text">
<div class="title">
<span class="pid">{{ item.displayId }}</span
>{{ item.title }}
</div>
<div class="meta">
交了 {{ item.attempts }} 次<template v-if="statusName(item.lastResult)">
· 最后一次{{ statusName(item.lastResult) }}</template
>
</div>
</div>
<router-link v-if="username" :to="submissions(item)" class="link">看提交 ›</router-link>
<n-button
v-else
type="primary"
size="small"
@click="router.push(`/problem/${item.displayId}`)"
>
接着做
</n-button>
</div>
</section>
</template>
<style scoped>
.card {
padding: 16px 18px 8px;
border: 1px solid rgba(240, 160, 32, 0.4);
border-radius: var(--oj-radius);
background: rgba(240, 160, 32, 0.06);
}
h3 {
margin: 0 0 6px;
font-size: var(--oj-fs-h2);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid rgba(240, 160, 32, 0.22);
}
.text {
flex: 1;
min-width: 0;
}
.title {
font-size: var(--oj-fs-body);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pid {
margin-right: 6px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.meta {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.link {
font-size: var(--oj-fs-meta);
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import type { LearningItem } from "@oj2/contract"
import { JUDGE_STATUS } from "utils/constants"
const WEEKDAY = "日一二三四五六"
/** `2026-09-18` → 9月18日 / 周五。日历日本身就是东八区的,直接拆字符串,不经 Date */
export function dayLabel(day: string) {
const [year, month, date] = day.split("-").map(Number) as [number, number, number]
const weekday = new Date(Date.UTC(year, month - 1, date)).getUTCDay()
return { date: `${month}月${date}日`, weekday: `周${WEEKDAY[weekday]}` }
}
export function statusName(result: number | null) {
if (result === null) return ""
return JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? ""
}
export function isDone(item: LearningItem) {
return item.status === "first" || item.status === "tries"
}
+10 -30
View File
@@ -1,5 +1,7 @@
import { import {
type AiAnalysisRecord, type AiAnalysisRecord,
type LearningPeriod,
type LearningReport,
type AiHintFeedbackRequest, type AiHintFeedbackRequest,
type Contest, type Contest,
type ContestAccess, type ContestAccess,
@@ -35,10 +37,6 @@ import {
type FlowchartScores, type FlowchartScores,
type FlowchartList, type FlowchartList,
type FlowchartSubmission, type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type SolvedList,
type ProblemSet, type ProblemSet,
type ProblemSetClassView, type ProblemSetClassView,
type ProblemSetList, type ProblemSetList,
@@ -409,38 +407,20 @@ export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } }) return api.get<TutorialSummary[]>("tutorials", { params: { type } })
} }
export function getAIDetailData(start: string, end: string, username?: string) { /** 智能分析整页的数据。username 只有老师传才生效,学生传了也只会拿到自己 */
return api.get<AiDetail>("ai/detail", { params: { start, end, username } }) export function getLearningReport(period: LearningPeriod, username?: string) {
} return api.get<LearningReport>("ai/report", {
params: { period, username: username || undefined },
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(end: string, duration: string, username?: string) { /** 这个时间段上次生成的 AI 小结(老师钉住的优先),没有为 null */
return api.get<DurationData[]>("ai/duration", { export function getLatestAnalysis(period: LearningPeriod, username?: string) {
params: { end, duration, username }, return api.get<AiAnalysisRecord | null>("ai/analysis/latest", {
params: { period, username: username || undefined },
}) })
} }
export function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */ /** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) { export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, { return api.post<null>(`ai/hint/${hintId}/feedback`, {
+1 -1
View File
@@ -196,7 +196,7 @@ function threatNote(me: RankRow, behind: RankRow) {
> >
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" /> <path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg> </svg>
看我的智能分析:哪类题卡得多、哪段时间最常做题 › 看我的智能分析:每节课做得怎样、哪几道还差一点 ›
</router-link> </router-link>
</div> </div>
</template> </template>
-203
View File
@@ -1,203 +0,0 @@
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
import { streamAI } from "utils/stream"
import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
getAISolved,
} from "../api"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
const targetUsername = ref("")
// 生成 AI 分析时要把同一段时间原样报给后端(数据由后端重算,前端只报范围)
const rangeStart = ref("")
const rangeEnd = ref("")
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
username: "",
start: "",
end: "",
grade: "",
className: null,
tags: {},
difficulty: {},
contestCount: 0,
solvedCount: 0,
attempts: [],
flowcharts: [],
activity: [],
errors: [],
rankScope: "global",
})
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({
fetching: false, // 合并 details 和 duration 的 loading
ai: false,
heatmap: false,
solved: false,
})
const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) {
const res = await getAIDetailData(start, end, targetUsername.value || undefined)
detailsData.start = res.start
detailsData.end = res.end
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
detailsData.flowcharts = res.flowcharts
}
async function fetchDurationData(end: string, duration: string) {
const res = await getAIDurationData(end, duration, targetUsername.value || undefined)
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)
heatmapData.value = res
loading.heatmap = false
}
async function fetchAnalysisData(start: string, end: string, duration: string) {
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
}
}
let aiController: AbortController | null = null
// 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个
// loading 放在同一个 reactive 里给页面用
async function fetchAIAnalysis() {
aiController?.abort()
const controller = new AbortController()
aiController = controller
loading.ai = true
mdContent.value = ""
try {
await streamAI(
"ai/analysis",
{
start: rangeStart.value,
end: rangeEnd.value,
duration: duration.value,
username: targetUsername.value || undefined,
},
{
signal: controller.signal,
onDelta(content) {
loading.ai = false
mdContent.value += content
},
},
)
} catch (error) {
if (controller.signal.aborted) return
mdContent.value = `生成失败:${(error as Error).message}`
} finally {
if (aiController === controller) {
aiController = null
loading.ai = false
}
}
}
async function fetchPinnedReport() {
const res = await getAIPinnedReport()
pinnedReport.value = res
}
async function simulatePinnedStream() {
if (!pinnedReport.value) return
const text = pinnedReport.value.analysis
mdContent.value = ""
const CHUNK = 6
const DELAY = 18
await new Promise<void>((resolve) => {
let i = 0
function step() {
if (i >= text.length) {
resolve()
return
}
mdContent.value += text.slice(i, i + CHUNK)
i += CHUNK
setTimeout(step, DELAY)
}
step()
})
}
return {
fetchAnalysisData,
fetchHeatmapData,
fetchSolved,
solvedRows,
solvedTotal,
solvedPage,
solvedPageSize,
fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData,
detailsData,
heatmapData,
duration,
targetUsername,
loading,
mdContent,
pinnedReport,
}
})
+1 -1
View File
@@ -171,7 +171,7 @@ onMounted(() => {
@click=" @click="
router.push({ router.push({
name: 'ai', name: 'ai',
query: { username: profile.user.username, duration: 'months:6' }, query: { username: profile.user.username },
}) })
" "
> >
+1 -11
View File
@@ -9,7 +9,6 @@ import type {
UserProfile, UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission, EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage, Message as ContractMessage,
Grade,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules, AstRules,
@@ -465,16 +464,7 @@ export type Exercise = Omit<AdminExercise, "data"> & {
| ExerciseGroupData | ExerciseGroupData
} }
export type { export type { ProblemDifficulty }
DurationData,
FlowchartSummary,
SolvedProblem,
SolvedList,
AiDetail as DetailsData,
} from "@oj2/contract"
// 评级。空串是「无评级」,后端在没有可用数据时真会下发,见契约 gradeSchema
export type { Grade, ProblemDifficulty }
// ==================== 成就相关类型 ==================== // ==================== 成就相关类型 ====================
+72 -104
View File
@@ -3,114 +3,97 @@ import { z } from "zod"
import { classPkPeriodSchema } from "./ranking" import { classPkPeriodSchema } from "./ranking"
/** /**
* 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 —— * 智能分析看哪段时间。全按东八区日历切(同排名页):这周 = 本周一零点起,这个月 = 本月 1 日
* 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。 * 零点起,这学期 = `termStart()`(9 月 1 日 / 2 月 1 日),全部 = 不设下限。
*/ */
export const gradeSchema = z.enum(["S", "A", "B", "C", ""]) export const learningPeriodSchema = z.enum(["week", "month", "term", "all"])
export const durationDataSchema = z.object({ export const LEARNING_PERIOD_LABELS = {
unit: z.string(), week: "这周",
index: z.number().int(), month: "这个月",
start: z.string(), term: "这学期",
end: z.string(), all: "全部",
grade: gradeSchema, } as const satisfies Record<z.infer<typeof learningPeriodSchema>, string>
problemCount: z.number().int(),
/** 该周期内判为通过的提交数。problemCount 是去重后的题数,两者不能互相代替 */
acceptedCount: z.number().int(),
submissionCount: z.number().int(),
})
export const solvedProblemSchema = z.object({ /**
problem: z.object({ * 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂看板:代码 AC /
title: z.string(), * AST_CHECK_FAILED、流程图评到 A / S 都算做对。
displayId: z.string(), *
contestTitle: z.string(), * - `first`:第一次交就对了
contestId: z.number().int().nullable(), * - `tries`:交了 `attempts` 次才对(含做对那次)
}), * - `fail`:交了 `attempts` 次,还没做对
acTime: z.string(), * - `none`:班上这节课做了,这个学生一次没交
rank: z.number().int().nullable(), */
acCount: z.number().int(), export const learningItemSchema = z.object({
grade: gradeSchema, displayId: z.string(),
periodRank: z.number().int().nullable(), title: z.string(),
periodAcCount: z.number().int(), status: z.enum(["first", "tries", "fail", "none"]),
difficulty: z.string(),
/** 到首次通过为止在这道题上提交了几次(含通过那次)。1 就是一次过 */
attempts: z.number().int(), attempts: z.number().int(),
}) /** 班上第几个做对(只数这段时间里做对的同班同学);没做对、没有班级时为 null */
rank: z.number().int().nullable(),
export const flowchartSummarySchema = z.object({ /** 这段时间班上几人做对;没有班级时为 null */
problemId: z.string(), classSolved: z.number().int().nullable(),
problemTitle: z.string(), /** 没做对时最后一次代码提交的判题状态;只交过流程图时为 null */
submissionCount: z.number().int(), lastResult: z.number().int().nullable(),
bestScore: z.number(),
bestGrade: z.string(),
latestSubmissionTime: z.string(),
avgScore: z.number(),
}) })
/** /**
* 时间活跃度的一个格子。星期和时段都按东八区切,和热力图同一个口径 —— * 一天一行。`lesson` 是班上这节课的题:老师在课堂看板布置过就用布置的,否则同班同一天
* 别让容器或数据库的 TZ 决定「学生周几晚上做题多」。 * ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、
* 不属于任何一节课的题,按他第一次交的那天归。
*/ */
export const activityBucketSchema = z.object({ export const learningDaySchema = z.object({
/** 0=周日 … 6=周六 */ /** 东八区日历日 `YYYY-MM-DD` */
weekday: z.number().int().min(0).max(6), day: z.string(),
/** 0=凌晨(0-6) 1=上午(6-12) 2=下午(12-18) 3=晚上(18-24) */ lesson: z.array(learningItemSchema),
period: z.number().int().min(0).max(3), own: z.array(learningItemSchema),
count: z.number().int(),
}) })
export const aiDetailSchema = z.object({ export const learningReportSchema = z.object({
username: z.string(), username: z.string(),
className: z.string().nullable(), className: z.string().nullable(),
start: z.string(), period: learningPeriodSchema,
start: z.string().nullable(),
end: z.string(), end: z.string(),
solved: z.number().int(),
firstTry: z.number().int(),
/** 这段时间班上上了几节课(有 `lesson` 的天数) */
lessonCount: z.number().int(),
/** 代码 + 流程图提交次数 */
submissionCount: z.number().int(),
/** 交过还没做对的题,交得多的在前 */
unsolved: z.array(learningItemSchema),
/** /**
* 区间内做出来的题数。逐题明细走 `GET /ai/solved` 分页拿 —— * 和班上比。「班上」= 这段时间交过题的同班学生(同排名口径的学生角色),没有班级时为 null。
* 一个活跃学生一年能做几百道,整份塞进这个响应没有必要。 * `rank` 是按做对题数排第几(比他多的人数 + 1)。
*/ */
solvedCount: z.number().int(), classStats: z
/** 每道做出来的题「到首次通过为止提交了几次」,分档放在前端 */ .object({
attempts: z.array(z.number().int()), size: z.number().int(),
flowcharts: z.array(flowchartSummarySchema), medianSolved: z.number(),
grade: gradeSchema, medianFirstPercent: z.number().int(),
tags: z.record(z.string(), z.number().int()), rank: z.number().int(),
difficulty: z.record(z.string(), z.number().int()), })
contestCount: z.number().int(), .nullable(),
/** 时间活跃度:按**全部提交**统计,不是只统计 AC */ /** 新的在前 */
activity: z.array(activityBucketSchema), days: z.array(learningDaySchema),
/** /** 没通过的代码提交按判题状态分组,多的在前;系统错误、还没判完的不算 */
* 判完的失败提交按状态码分组,多的在前。状态码是落库的值, errors: z.array(z.object({ result: z.number().int(), count: z.number().int() })),
* 前端用 utils/constants 的 JUDGE_STATUS 翻成中文,两边必须一致。 /** 全部知识点(tag 的 knowledge 类),按题库里题数从多到少;没碰过的 solved = tried = 0 */
*/ knowledge: z.array(
errors: z.array( z.object({ name: z.string(), solved: z.number().int(), tried: z.number().int() }),
z.object({
result: z.number().int(),
count: z.number().int(),
}),
), ),
/** /** 老师看学生时,同班按用户名排的前一个 / 后一个;学生看自己时为 null */
* solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服, neighbors: z.object({ prev: z.string().nullable(), next: z.string().nullable() }).nullable(),
* 前端不能只看 className 有没有值就写「班级排名」。
*/
rankScope: z.enum(["class", "global"]),
}) })
/** /**
* 请求只说「看谁、哪段时间、按什么粒度」,学情数据由服务端自己算。 * 请求只说「看谁、哪段时间」,学情数据由服务端自己算。
* 以前是 `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), period: learningPeriodSchema,
end: z.string().min(1),
duration: z.string().min(1),
username: z.string().optional(), username: z.string().optional(),
}) })
@@ -248,17 +231,6 @@ export const classPkAnalysisRequestSchema = z.object({
period: classPkPeriodSchema, period: classPkPeriodSchema,
}) })
/**
* 热力图的一格 = **一周**(不是一天)。timestamp 是那一周(按东八区日历)周一的 UTC 零点,
* 前端按东八区取年月日(`zonedParts`),不要用浏览器本地部件。
* value 是整周的提交次数。按天切的话一年 365 格里三百多格是空的,
* 中职学生一年也就在二三十天有提交,整张图看着像没用过。
*/
export const heatmapItemSchema = z.object({
timestamp: z.number(),
value: z.number().int(),
})
export const aiAnalysisRecordSchema = z.object({ export const aiAnalysisRecordSchema = z.object({
id: z.number().int(), id: z.number().int(),
provider: z.string(), provider: z.string(),
@@ -270,14 +242,10 @@ export const aiAnalysisRecordSchema = z.object({
username: z.string().optional(), username: z.string().optional(),
}) })
export type Grade = z.infer<typeof gradeSchema> export type LearningPeriod = z.infer<typeof learningPeriodSchema>
export type DurationData = z.infer<typeof durationDataSchema> export type LearningItem = z.infer<typeof learningItemSchema>
export type SolvedProblem = z.infer<typeof solvedProblemSchema> export type LearningDay = z.infer<typeof learningDaySchema>
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema> export type LearningReport = z.infer<typeof learningReportSchema>
export type ActivityBucket = z.infer<typeof activityBucketSchema>
export type AiDetail = z.infer<typeof aiDetailSchema>
export type SolvedList = z.infer<typeof solvedListSchema>
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema> export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema> export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>