Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4abbc1645 | ||
|
|
890e731a1c |
No files matched your search
+83
-157
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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`, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user