refactor(智能分析): 重排版面,砍掉重复的三张周期图,补三个新维度
原来 8 张图挤在两列大网格里,左列还套了一层两列小网格 —— 四张小图各自只有半页的 一半宽,"难度掌握情况"的标题被挤断成两行、周期图的 x 轴标签斜着叠在一起、一年热力图 53 列塞进半宽几乎看不清。改成单列为主,宽的图给全宽,窄的图两两并排。 砍掉的重复:进步曲线 / 提交效率 / 周期综合读的是同一个 durationData,半年视图统共 6 个桶四个字段,摊成三张卡六条曲线还都是双轴。合并成一张:柱是完成题目数和总提交数, 线是 AC 率,等级进 tooltip(S/A/B/C 四档离散值连成折线读不出东西,逐题等级表格里有)。 同期解题排名分布也砍了:五片扇形对十几道题做统计本来就是噪声,而排名逐题列在 SolvedTable 里,饼图没有增加任何信息。 换形式: - 标签雷达图 → 横向条形。雷达对比较大小是最差的形式之一,原来还把值归一化成"占最多 标签的百分比",第一名恒为 100%,等于只画了个排序。现在画真实题数。 - 难度掌握情况 → 难度分布。去掉叠在里面的 S/A/B/C 维度,3×4 十二个格子对一个两个月 做十来道题的学生大部分恒为 0。 热力图改成一格一周(53 格,周一起算,最后一格是本周)。按天切的话一年 365 格里三百多 格是空的,中职学生一年也就二三十天有提交,整张图看着像没用过。颜色阈值跟着按周重定。 新增三个维度: - 错在哪里:判完的失败提交按状态码分组。编译错误占大头说明语法不熟,答案错误占大头 说明是逻辑问题,两种情况老师该给的建议完全不同。 - 几次做对:到首次通过为止提交了几次,分一次过 / 2-3 / 4-6 / 7次以上。原来只有一个 "平均提交次数",看不出分布。 - 流程图得分:detailsData.flowcharts 早就在下发,但全页一张图都没有,只在解题表格的 第二个 tab 里列着。 顺带: - 时间活跃度从"只统计 AC 时间"改成"统计全部提交",星期和时段由后端按东八区聚合。 只看 AC 的话十来个点撒进 7×4 的格子几乎全是空的,和热力图的时区口径也对不上。 - 两两并排用弹性容器而不是固定两列网格:知识点分布在没有标签时整张卡不渲染, 固定两列会空掉一半。 - AI 卡片原来有三个条件挂载点(solved>10 在右列、≤10 在全宽行、=0 藏在 Overview 里面),单列之后收成最后一张无条件的卡。 - DurationChart 右轴的 S/A/B/C 一个刻度都没显示过:轴范围 -0.5~3.5,生成的刻度值是 -0.5/0.5/1.5/2.5/3.5,拿去索引 gradeOrder 全是 undefined。 契约新增 activity / errors / rankScope / solved[].attempts / durationData[].acceptedCount。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
This commit is contained in:
@@ -94,6 +94,38 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildDetail(user: AuthUser, start: string, end: string) {
|
async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||||
|
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
||||||
|
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||||
|
// 星期和小时都按东八区取,和热力图同口径;时区用 sql.raw 拼进去,
|
||||||
|
// 绑成参数的话 select 和 group by 会拿到不同占位符,PG 不认为是同一个表达式。
|
||||||
|
const weekday = sql<number>`extract(dow from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::int`.mapWith(Number)
|
||||||
|
const period = sql<number>`floor(extract(hour from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL}) / 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: number) =>
|
||||||
|
!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 db.select({ problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
|
const firstAc = await db.select({ problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
|
||||||
.from(schema.submission).where(and(
|
.from(schema.submission).where(and(
|
||||||
eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted),
|
eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted),
|
||||||
@@ -102,7 +134,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
const problemIds = firstAc.map((item) => item.problemId)
|
const problemIds = firstAc.map((item) => item.problemId)
|
||||||
if (!problemIds.length) return aiDetailSchema.parse({
|
if (!problemIds.length) return aiDetailSchema.parse({
|
||||||
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
||||||
rankScope: "global",
|
activity, errors, rankScope: "global",
|
||||||
})
|
})
|
||||||
const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : []
|
const 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 scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||||
@@ -122,6 +154,14 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
.where(and(eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.status, 2), gte(schema.flowchartSubmission.createTime, start), lte(schema.flowchartSubmission.createTime, end))),
|
.where(and(eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.status, 2), gte(schema.flowchartSubmission.createTime, start), lte(schema.flowchartSubmission.createTime, end))),
|
||||||
])
|
])
|
||||||
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
|
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
|
||||||
|
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
|
||||||
|
const firstAcTime = new Map(firstAc.flatMap((item) => (item.first ? [[item.problemId, Date.parse(item.first)]] as const : [])))
|
||||||
|
const attemptsByProblem = new Map<number, number>()
|
||||||
|
for (const row of submissions) {
|
||||||
|
const deadline = firstAcTime.get(row.problemId)
|
||||||
|
if (deadline === undefined || Date.parse(row.time) > deadline) continue
|
||||||
|
attemptsByProblem.set(row.problemId, (attemptsByProblem.get(row.problemId) ?? 0) + 1)
|
||||||
|
}
|
||||||
function ranks(rows: typeof rankRows, problemId: number) {
|
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)
|
return rows.filter((item) => item.problemId === problemId).sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId)
|
||||||
}
|
}
|
||||||
@@ -136,6 +176,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
|
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,
|
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
|
||||||
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
||||||
|
attempts: attemptsByProblem.get(item.problemId) ?? 1,
|
||||||
})
|
})
|
||||||
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
||||||
const tags: Record<string, number> = {}
|
const tags: Record<string, number> = {}
|
||||||
@@ -167,7 +208,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
|||||||
user: user.username, className: user.className, start, end, solved, flowcharts,
|
user: user.username, className: user.className, start, end, solved, flowcharts,
|
||||||
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
||||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||||
rankScope: scopeIds ? "class" : "global",
|
activity, errors, rankScope: scopeIds ? "class" : "global",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,20 +328,32 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
|||||||
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 end = new Date()
|
const end = new Date()
|
||||||
// 365 格里最后一格是今天。原来退 365 天再往前数 365 格,最后一格落在昨天 ——
|
// 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。
|
||||||
// 学生刚交完题打开热力图,今天那格永远是空的
|
// 日期部件全部取自东八区,再用它们构造本地零点的 Date 做日历运算 ——
|
||||||
const start = new Date(end.getTime() - 364 * 864e5)
|
// 前端 new Date(timestamp) 后取的也是本地部件,这样两边看到的是同一个日历日。
|
||||||
|
const [nowYear, nowMonth, nowDay] = calendarDay.format(end).split("-").map(Number)
|
||||||
|
const today = new Date(nowYear!, nowMonth! - 1, nowDay!)
|
||||||
|
const mondayOffset = (today.getDay() + 6) % 7
|
||||||
|
const firstMonday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - mondayOffset - 52 * 7)
|
||||||
|
// SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表
|
||||||
const date = sql<string>`date(${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::text`
|
const date = sql<string>`date(${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::text`
|
||||||
const rows = await db.select({ date, value: count() }).from(schema.submission)
|
const rows = await db.select({ date, value: count() }).from(schema.submission)
|
||||||
.where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString())))
|
.where(and(
|
||||||
.groupBy(date).orderBy(date)
|
eq(schema.submission.userId, user.id),
|
||||||
|
gte(schema.submission.createTime, new Date(firstMonday.getTime() - 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]))
|
const counts = new Map(rows.map((row) => [row.date, row.value]))
|
||||||
return success(c, Array.from({ length: 365 }, (_, index) => {
|
const dateKey = (value: Date) =>
|
||||||
const key = calendarDay.format(new Date(start.getTime() + index * 864e5))
|
`${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`
|
||||||
const [year, month, day] = key.split("-").map(Number)
|
return success(c, Array.from({ length: 53 }, (_, week) => {
|
||||||
// 时间戳给「该日历日的本地零点」:前端 Heatmap.vue 是 new Date(timestamp) 再取
|
const monday = new Date(firstMonday.getFullYear(), firstMonday.getMonth(), firstMonday.getDate() + week * 7)
|
||||||
// getMonth/getDay,按日期部件构造才能保证渲染出来的就是这一天
|
let value = 0
|
||||||
return heatmapItemSchema.parse({ timestamp: new Date(year!, month! - 1, day!).getTime(), value: counts.get(key) ?? 0 })
|
for (let offset = 0; offset < 7; offset++) {
|
||||||
|
const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset)
|
||||||
|
value += counts.get(dateKey(day)) ?? 0
|
||||||
|
}
|
||||||
|
return heatmapItemSchema.parse({ timestamp: monday.getTime(), value })
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.fetching" :delay="50">
|
<n-spin :show="aiStore.loading.fetching" :delay="50">
|
||||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
<n-flex vertical size="large">
|
||||||
<n-gi :span="1">
|
<n-flex align="center" justify="space-between">
|
||||||
<n-flex vertical size="large">
|
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||||
<n-flex align="center" justify="space-between">
|
<n-flex align="center">
|
||||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
<n-input
|
||||||
<n-flex align="center">
|
v-if="userStore.isSuperAdmin"
|
||||||
<n-input
|
v-model:value="urlUsername"
|
||||||
v-if="userStore.isSuperAdmin"
|
placeholder="查看指定用户"
|
||||||
v-model:value="urlUsername"
|
clearable
|
||||||
placeholder="查看指定用户"
|
style="width: 140px"
|
||||||
clearable
|
@change="onUsernameChange"
|
||||||
style="width: 140px"
|
@clear="onUsernameChange"
|
||||||
@change="onUsernameChange"
|
/>
|
||||||
@clear="onUsernameChange"
|
<n-select
|
||||||
/>
|
style="width: 140px"
|
||||||
<n-select
|
:options="options"
|
||||||
style="width: 140px"
|
v-model:value="urlDuration"
|
||||||
:options="options"
|
/>
|
||||||
v-model:value="urlDuration"
|
|
||||||
/>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
<Overview />
|
|
||||||
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
|
||||||
<n-gi :span="isDesktop ? 1 : 2">
|
|
||||||
<DifficultyGradeChart />
|
|
||||||
</n-gi>
|
|
||||||
<n-gi :span="isDesktop ? 1 : 2">
|
|
||||||
<TagsRadarChart />
|
|
||||||
</n-gi>
|
|
||||||
<n-gi :span="isDesktop ? 1 : 2">
|
|
||||||
<RankDistributionChart />
|
|
||||||
</n-gi>
|
|
||||||
<n-gi :span="isDesktop ? 1 : 2">
|
|
||||||
<TimeActivityHeatmap />
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
<SolvedTable />
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-flex>
|
||||||
<n-gi :span="1">
|
|
||||||
<n-flex vertical size="large">
|
<Overview />
|
||||||
<Heatmap />
|
<Heatmap />
|
||||||
<ProgressChart />
|
<DurationChart />
|
||||||
<EfficiencyChart />
|
<!-- 用弹性排布而不是两列网格:知识点分布在没有标签时整张卡不渲染,
|
||||||
<DurationChart />
|
固定两列会空掉一半;flex 下剩的那张自动占满整行 -->
|
||||||
<AI v-if="aiStore.detailsData.solved.length > 10" />
|
<n-flex class="pair" :size="20">
|
||||||
</n-flex>
|
<DifficultyGradeChart />
|
||||||
</n-gi>
|
<TagsChart />
|
||||||
<n-gi :span="2">
|
</n-flex>
|
||||||
<AI
|
<n-flex class="pair" :size="20">
|
||||||
v-if="
|
<ErrorTypeChart />
|
||||||
aiStore.detailsData.solved.length > 0 &&
|
<AttemptsChart />
|
||||||
aiStore.detailsData.solved.length <= 10
|
</n-flex>
|
||||||
"
|
<n-flex class="pair" :size="20">
|
||||||
/>
|
<TimeActivityHeatmap />
|
||||||
</n-gi>
|
<FlowchartScoreChart />
|
||||||
</n-grid>
|
</n-flex>
|
||||||
|
<SolvedTable />
|
||||||
|
<AI />
|
||||||
|
</n-flex>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
|
||||||
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||||
|
import TagsChart from "./components/TagsChart.vue"
|
||||||
|
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 TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
|
||||||
import RankDistributionChart from "./components/RankDistributionChart.vue"
|
|
||||||
import Overview from "./components/Overview.vue"
|
import Overview from "./components/Overview.vue"
|
||||||
import Heatmap from "./components/Heatmap.vue"
|
import Heatmap from "./components/Heatmap.vue"
|
||||||
import ProgressChart from "./components/ProgressChart.vue"
|
|
||||||
import DurationChart from "./components/DurationChart.vue"
|
import DurationChart from "./components/DurationChart.vue"
|
||||||
import EfficiencyChart from "./components/EfficiencyChart.vue"
|
|
||||||
import AI from "./components/AI.vue"
|
import AI from "./components/AI.vue"
|
||||||
import SolvedTable from "./components/SolvedTable.vue"
|
import SolvedTable from "./components/SolvedTable.vue"
|
||||||
import { useAIStore } from "../store/ai"
|
import { useAIStore } from "../store/ai"
|
||||||
@@ -81,8 +63,6 @@ import { DURATION_OPTIONS } from "utils/constants"
|
|||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const options = [...DURATION_OPTIONS]
|
const options = [...DURATION_OPTIONS]
|
||||||
|
|
||||||
const urlUsername = useRouteQuery<string>("username", "")
|
const urlUsername = useRouteQuery<string>("username", "")
|
||||||
@@ -120,3 +100,8 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.pair > :deep(.n-card) {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 只按钮那一屏的时候别留一大片空白;出内容后高度由内容自己撑,
|
||||||
|
这里只是给加载中的 spin 一点地方 */
|
||||||
.container {
|
.container {
|
||||||
min-height: 200px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.md-editor-preview h1) {
|
:deep(.md-editor-preview h1) {
|
||||||
|
|||||||
100
apps/web/src/oj/ai/components/AttemptsChart.vue
Normal file
100
apps/web/src/oj/ai/components/AttemptsChart.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<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()
|
||||||
|
|
||||||
|
// solved[].attempts 是后端算的「到首次通过为止的提交次数」,分档放在前端,
|
||||||
|
// 想调档位不用改契约
|
||||||
|
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,
|
||||||
|
problems: aiStore.detailsData.solved.filter(
|
||||||
|
(item) => item.attempts >= bucket.min && item.attempts <= bucket.max,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const show = computed(() => aiStore.detailsData.solved.length > 0)
|
||||||
|
|
||||||
|
const data = computed(() => ({
|
||||||
|
labels: buckets.value.map((bucket) => bucket.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "题目数量",
|
||||||
|
data: buckets.value.map((bucket) => bucket.problems.length),
|
||||||
|
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) => `${ctx.parsed.y} 道题`,
|
||||||
|
afterLabel: (ctx) => {
|
||||||
|
const titles = buckets.value[ctx.dataIndex]!.problems.map(
|
||||||
|
(item) => `${item.problem.displayId} ${item.problem.title}`,
|
||||||
|
)
|
||||||
|
if (!titles.length) return ""
|
||||||
|
if (titles.length <= 5) return titles
|
||||||
|
return [...titles.slice(0, 4), `… 还有 ${titles.length - 4} 道`]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.chart {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card title="难度掌握情况" size="small" v-if="show">
|
<n-card title="难度分布" size="small" v-if="show">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px">
|
<n-text depth="3" style="font-size: 12px">
|
||||||
了解不同难度题目的完成等级分布
|
看看简单题和难题各做了多少
|
||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div style="height: 300px">
|
<div style="height: 300px">
|
||||||
@@ -12,136 +12,72 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChartOptions } from "chart.js"
|
||||||
import { Bar } from "vue-chartjs"
|
import { Bar } from "vue-chartjs"
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
BarElement,
|
BarElement,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
import type { Grade } from "utils/types"
|
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
const { chartKey } = useChartTheme()
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
// 难度和等级的顺序(后端返回的是中文)
|
// 难度和等级的顺序(后端返回的是中文)
|
||||||
const difficultyOrder = ["简单", "中等", "困难"]
|
const difficultyOrder = ["简单", "中等", "困难"]
|
||||||
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
|
const difficultyColors = ["#18A058", "#F0A020", "#D03050"]
|
||||||
|
|
||||||
// 统计每个难度-等级组合的题目数量
|
// 只按难度分。原来还往里叠了一层 S/A/B/C 等级,3×4 十二个格子,
|
||||||
const matrix = computed(() => {
|
// 学生两个月做十来道题的话大部分格子恒为 0;等级信息在下面的解题表格里逐题都有
|
||||||
const result: { [difficulty: string]: { [grade: string]: number } } = {}
|
const counts = computed(() =>
|
||||||
|
difficultyOrder.map(
|
||||||
|
(name) =>
|
||||||
|
aiStore.detailsData.solved.filter((item) => item.difficulty === name)
|
||||||
|
.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// 初始化矩阵
|
const show = computed(() => aiStore.detailsData.solved.length > 0)
|
||||||
difficultyOrder.forEach((diff) => {
|
|
||||||
result[diff] = {}
|
|
||||||
gradeOrder.forEach((grade) => {
|
|
||||||
result[diff][grade] = 0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 统计数据
|
const data = computed(() => ({
|
||||||
aiStore.detailsData.solved.forEach((item) => {
|
labels: difficultyOrder,
|
||||||
const diff = item.difficulty
|
datasets: [
|
||||||
const grade = item.grade
|
{
|
||||||
if (diff && grade && result[diff]) {
|
label: "完成题目数",
|
||||||
result[diff][grade]++
|
data: counts.value,
|
||||||
}
|
backgroundColor: difficultyColors,
|
||||||
})
|
borderColor: difficultyColors,
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = computed(() => {
|
|
||||||
return aiStore.detailsData.solved.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 为每个等级准备数据集
|
|
||||||
const data = computed(() => {
|
|
||||||
// 为每个等级生成一个 dataset
|
|
||||||
const datasets = gradeOrder.map((grade) => {
|
|
||||||
return {
|
|
||||||
label: `等级 ${grade}`,
|
|
||||||
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
|
|
||||||
backgroundColor: getGradeColor(grade),
|
|
||||||
borderColor: getGradeColor(grade),
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}
|
borderRadius: 4,
|
||||||
})
|
maxBarThickness: 64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
const options = computed<ChartOptions<"bar">>(() => ({
|
||||||
labels: difficultyOrder,
|
|
||||||
datasets,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 根据等级返回对应的颜色
|
|
||||||
function getGradeColor(grade: Grade): string {
|
|
||||||
const colors: { [key in Grade]: string } = {
|
|
||||||
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
|
|
||||||
S: "#FF6384",
|
|
||||||
A: "#FFCE56",
|
|
||||||
B: "#36A2EB",
|
|
||||||
C: "#95F204",
|
|
||||||
}
|
|
||||||
return colors[grade]
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: "index" as const,
|
|
||||||
},
|
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: { grid: { display: false } },
|
||||||
stacked: true,
|
|
||||||
grid: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
y: {
|
||||||
stacked: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: { stepSize: 1, precision: 0 },
|
||||||
stepSize: 1,
|
title: { display: true, text: "题目数量" },
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "题目数量",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: { display: false },
|
||||||
display: true,
|
|
||||||
position: "bottom" as const,
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
padding: 8,
|
|
||||||
font: {
|
|
||||||
size: 11,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
footer: (items: any[]) => {
|
label: (ctx) => `完成 ${ctx.parsed.y} 道题`,
|
||||||
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
|
||||||
return `该难度总计: ${total} 题`
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card :title="title" size="small">
|
<n-card :title="title" size="small" v-if="show">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
做题量和提交质量的变化
|
||||||
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
|
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
|
||||||
@@ -18,11 +20,11 @@ import {
|
|||||||
BarElement,
|
BarElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
PointElement,
|
PointElement,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Colors,
|
Colors,
|
||||||
LineController,
|
LineController,
|
||||||
|
BarController,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
import { useChartTheme } from "shared/composables/chartTheme"
|
||||||
@@ -35,161 +37,143 @@ ChartJS.register(
|
|||||||
BarElement,
|
BarElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
PointElement,
|
PointElement,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Colors,
|
Colors,
|
||||||
LineController,
|
LineController,
|
||||||
|
BarController,
|
||||||
)
|
)
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
const { chartKey } = useChartTheme()
|
const { chartKey } = useChartTheme()
|
||||||
|
|
||||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (aiStore.duration === "months:2") {
|
if (aiStore.duration === "months:2") {
|
||||||
return "过去两个月的每周综合情况"
|
return "过去两个月的每周情况"
|
||||||
} else if (aiStore.duration === "months:6") {
|
} else if (aiStore.duration === "months:6") {
|
||||||
return "过去半年的每月综合情况"
|
return "过去半年的每月情况"
|
||||||
} else if (aiStore.duration === "years:1") {
|
} else if (aiStore.duration === "years:1") {
|
||||||
return "过去一年的每月综合情况"
|
return "过去一年的每月情况"
|
||||||
} else {
|
} else {
|
||||||
return "过去四周的综合情况"
|
return "过去四周的情况"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
const show = computed(() => aiStore.durationData.length > 0)
|
||||||
return {
|
|
||||||
labels: aiStore.durationData.map((duration) => {
|
|
||||||
return [
|
|
||||||
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: "等级",
|
|
||||||
data: aiStore.durationData.map((duration) =>
|
|
||||||
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
|
|
||||||
),
|
|
||||||
spanGaps: false,
|
|
||||||
tension: 0.4,
|
|
||||||
yAxisID: "y1",
|
|
||||||
order: 1,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointHoverRadius: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
// 这一张顶掉了原来的三张(进步曲线 / 提交效率 / 周期综合)—— 它们读的是同一个
|
||||||
return {
|
// durationData,半年视图统共就 6 个桶、四个字段,没有必要摊成三张卡六条曲线。
|
||||||
interaction: {
|
// 等级放进 tooltip 不再单独占一条轴:S/A/B/C 是四档离散值,连成折线读不出东西,
|
||||||
intersect: false,
|
// 而且逐题的等级在下面的解题表格里本来就有
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
maintainAspectRatio: false,
|
{
|
||||||
scales: {
|
type: "bar",
|
||||||
x: {
|
label: "总提交次数",
|
||||||
grid: {
|
data: aiStore.durationData.map((duration) => duration.submissionCount),
|
||||||
display: false,
|
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 },
|
||||||
},
|
},
|
||||||
y: {
|
},
|
||||||
ticks: {
|
title: { display: false },
|
||||||
stepSize: 1,
|
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}`
|
||||||
},
|
},
|
||||||
title: {
|
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||||
display: true,
|
const index = items[0]?.dataIndex
|
||||||
text: "数量",
|
const bucket =
|
||||||
},
|
index === undefined ? undefined : aiStore.durationData[index]
|
||||||
beginAtZero: true,
|
if (!bucket) return ""
|
||||||
},
|
const lines = [`本期等级: ${bucket.grade || "无"}`]
|
||||||
y1: {
|
if (bucket.submissionCount > 0) {
|
||||||
type: "linear",
|
lines.push(
|
||||||
position: "right",
|
`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`,
|
||||||
min: -0.5,
|
)
|
||||||
max: gradeOrder.length - 0.5,
|
}
|
||||||
ticks: {
|
return lines
|
||||||
stepSize: 1,
|
|
||||||
// 轴是 -0.5 ~ 3.5,chart.js 生成的刻度值就是 -0.5/0.5/1.5/2.5/3.5,
|
|
||||||
// 直接拿去索引 gradeOrder 全是 undefined —— 右轴的 S/A/B/C 一个都不会显示。
|
|
||||||
// 四舍五入到整数档再取,和 ProgressChart 的写法一致
|
|
||||||
callback: (v) => {
|
|
||||||
const idx = Math.round(Number(v))
|
|
||||||
return gradeOrder[idx] || ""
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "等级",
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
display: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
},
|
||||||
legend: {
|
}))
|
||||||
display: true,
|
|
||||||
position: "bottom" as const,
|
|
||||||
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 as any).yAxisID === "y1") {
|
|
||||||
const idx = Number(ctx.parsed.y)
|
|
||||||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
|
||||||
}
|
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
|
||||||
},
|
|
||||||
// AC 率直接读该周期的 acceptedCount / submissionCount。原来拿柱子上的
|
|
||||||
// 「完成题目数 / 总提交次数」当 AC 率,分子是去重后的题数,不是一回事
|
|
||||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
|
||||||
const index = items[0]?.dataIndex
|
|
||||||
const bucket =
|
|
||||||
index === undefined ? undefined : aiStore.durationData[index]
|
|
||||||
if (!bucket || bucket.submissionCount === 0) return ""
|
|
||||||
const rate = (bucket.acceptedCount / bucket.submissionCount) * 100
|
|
||||||
return `AC率: ${rate.toFixed(1)}%(通过 ${bucket.acceptedCount} / 共 ${bucket.submissionCount} 次)`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart {
|
.chart {
|
||||||
height: 300px;
|
height: 320px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,239 +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="line" :key="chartKey" :data="data" :options="options" />
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ChartData, TooltipItem } from "chart.js"
|
|
||||||
import { Chart } from "vue-chartjs"
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Filler,
|
|
||||||
} from "chart.js"
|
|
||||||
import { useAIStore } from "oj/store/ai"
|
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
|
|
||||||
// 注册折线图所需的 Chart.js 组件
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Filler,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
return aiStore.durationData.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算提交效率数据
|
|
||||||
const efficiencyData = computed(() => {
|
|
||||||
return aiStore.durationData.map((duration) => {
|
|
||||||
const problemCount = duration.problemCount || 0
|
|
||||||
const acceptedCount = duration.acceptedCount || 0
|
|
||||||
const submissionCount = duration.submissionCount || 0
|
|
||||||
|
|
||||||
// 计算效率:提交次数/完成题目数
|
|
||||||
// 值越接近1,说明一次AC率越高
|
|
||||||
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
|
|
||||||
|
|
||||||
// AC 率 = 判为通过的提交数 / 总提交数。原来分子用的是去重后的题目数,
|
|
||||||
// 那既不是 AC 率,算出来还正好是上面 efficiency 的倒数 —— 双轴画的是同一个数
|
|
||||||
const acRate =
|
|
||||||
submissionCount > 0 ? (acceptedCount / submissionCount) * 100 : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: [
|
|
||||||
parseTime(duration.start, "M月D日"),
|
|
||||||
parseTime(duration.end, "M月D日"),
|
|
||||||
].join("~"),
|
|
||||||
efficiency: efficiency,
|
|
||||||
acRate: acRate,
|
|
||||||
problemCount: problemCount,
|
|
||||||
acceptedCount: acceptedCount,
|
|
||||||
submissionCount: submissionCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图表数据
|
|
||||||
const data = computed<ChartData<"line">>(() => {
|
|
||||||
const efficiency = efficiencyData.value
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: efficiency.map((e) => e.label),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "平均提交次数",
|
|
||||||
data: efficiency.map((e) => e.efficiency),
|
|
||||||
borderColor: "rgb(99, 102, 241)",
|
|
||||||
backgroundColor: "rgba(99, 102, 241, 0.1)",
|
|
||||||
tension: 0.4,
|
|
||||||
fill: true,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
yAxisID: "y",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "提交AC率",
|
|
||||||
data: efficiency.map((e) => e.acRate),
|
|
||||||
borderColor: "rgb(34, 197, 94)",
|
|
||||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
|
||||||
tension: 0.4,
|
|
||||||
fill: true,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
pointBackgroundColor: "rgb(34, 197, 94)",
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
yAxisID: "y1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图表配置
|
|
||||||
const options = computed(() => {
|
|
||||||
return {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
mode: "index" as const,
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
autoSkip: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: "linear" as const,
|
|
||||||
position: "left" as const,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "平均提交次数(次/题)",
|
|
||||||
font: {
|
|
||||||
size: 13,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: function (value: string | number) {
|
|
||||||
return Number(value).toFixed(1)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: "linear" as const,
|
|
||||||
position: "right" as const,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "提交AC率(%)",
|
|
||||||
font: {
|
|
||||||
size: 13,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
callback: function (value: string | number) {
|
|
||||||
return Number(value).toFixed(0) + "%"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
padding: 12,
|
|
||||||
callbacks: {
|
|
||||||
label: function (ctx: TooltipItem<"line">) {
|
|
||||||
const index = ctx.dataIndex
|
|
||||||
const item = efficiencyData.value[index]
|
|
||||||
const dsLabel = ctx.dataset.label || ""
|
|
||||||
|
|
||||||
if (ctx.datasetIndex === 0) {
|
|
||||||
// 平均提交次数
|
|
||||||
return [
|
|
||||||
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
|
|
||||||
`完成题目: ${item.problemCount} 题`,
|
|
||||||
`总提交: ${item.submissionCount} 次`,
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// 提交AC率
|
|
||||||
return [
|
|
||||||
`${dsLabel}: ${item.acRate.toFixed(1)}%`,
|
|
||||||
`通过 ${item.acceptedCount} 次 / 共提交 ${item.submissionCount} 次`,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: "bottom" as const,
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
boxHeight: 12,
|
|
||||||
padding: 8,
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.chart {
|
|
||||||
height: 300px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
113
apps/web/src/oj/ai/components/ErrorTypeChart.vue
Normal file
113
apps/web/src/oj/ai/components/ErrorTypeChart.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<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>
|
||||||
99
apps/web/src/oj/ai/components/FlowchartScoreChart.vue
Normal file
99
apps/web/src/oj/ai/components/FlowchartScoreChart.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<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,13 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card title="过去一年的提交热力图" size="small">
|
<n-card title="过去一年的提交热力图" size="small">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
每格一周,激励持续学习
|
||||||
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||||
<div
|
<div
|
||||||
class="heatmap-container"
|
class="heatmap-container"
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
:style="{ '--cell-stroke': cellStroke, '--cell-stroke-hover': cellStrokeHover }"
|
:style="{
|
||||||
|
'--cell-stroke': cellStroke,
|
||||||
|
'--cell-stroke-hover': cellStrokeHover,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
@@ -20,28 +25,17 @@
|
|||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g v-for="(day, i) in WEEK_DAYS" :key="i">
|
<g :transform="`translate(0, ${MONTH_HEIGHT})`">
|
||||||
<text
|
|
||||||
:x="0"
|
|
||||||
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
|
|
||||||
class="label"
|
|
||||||
font-size="9"
|
|
||||||
>
|
|
||||||
{{ day }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
|
|
||||||
<rect
|
<rect
|
||||||
v-for="(cell, i) in cells"
|
v-for="(cell, i) in cells"
|
||||||
:key="i"
|
:key="i"
|
||||||
:x="cell.x"
|
:x="cell.x"
|
||||||
:y="cell.y"
|
:y="0"
|
||||||
:width="CELL_SIZE"
|
:width="CELL_SIZE"
|
||||||
:height="CELL_SIZE"
|
:height="CELL_HEIGHT"
|
||||||
:fill="cell.color"
|
:fill="cell.color"
|
||||||
class="cell"
|
class="cell"
|
||||||
rx="2"
|
rx="3"
|
||||||
@mouseenter="(e) => showTooltip(e, cell)"
|
@mouseenter="(e) => showTooltip(e, cell)"
|
||||||
@mouseleave="hideTooltip"
|
@mouseleave="hideTooltip"
|
||||||
/>
|
/>
|
||||||
@@ -68,16 +62,17 @@ const aiStore = useAIStore()
|
|||||||
const { isDark } = useChartTheme()
|
const { isDark } = useChartTheme()
|
||||||
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||||
|
|
||||||
const CELL_SIZE = 12
|
const CELL_SIZE = 22
|
||||||
const CELL_GAP = 3
|
// 一格一周之后只剩一行,格子做成竖长条,卡片不至于扁成一条缝
|
||||||
|
const CELL_HEIGHT = 34
|
||||||
|
const CELL_GAP = 4
|
||||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||||
const DAY_WIDTH = 20
|
const MONTH_HEIGHT = 18
|
||||||
const MONTH_HEIGHT = 20
|
|
||||||
const RIGHT_PADDING = 5
|
const RIGHT_PADDING = 5
|
||||||
|
|
||||||
// 深色下空格子不能再用接近白的 #ebedf0,整张图会变成一片发亮的方块
|
// 深色下空格子不能再用接近白的 #ebedf0,整张图会变成一片发亮的方块
|
||||||
const LIGHT_COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
const LIGHT_COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||||
const DARK_COLORS = ["#22272e", "#0e4429", "#006d32", "#26a641", "#39d353"]
|
const DARK_COLORS = ["#22272e", "#0e4429", "#006d32", "#26a641", "#39d353"]
|
||||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
|
||||||
|
|
||||||
const COLORS = computed(() => (isDark.value ? DARK_COLORS : LIGHT_COLORS))
|
const COLORS = computed(() => (isDark.value ? DARK_COLORS : LIGHT_COLORS))
|
||||||
const cellStroke = computed(() =>
|
const cellStroke = computed(() =>
|
||||||
@@ -87,34 +82,33 @@ const cellStrokeHover = computed(() =>
|
|||||||
isDark.value ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.3)",
|
isDark.value ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.3)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 阈值按「一周」定,不是按一天。按天的老阈值(>7 就到顶)放到周上,
|
||||||
|
// 稍微认真做几天题就全是最深的一档,看不出差别
|
||||||
const getColor = (count: number) => {
|
const getColor = (count: number) => {
|
||||||
const palette = COLORS.value
|
const palette = COLORS.value
|
||||||
if (count === 0) return palette[0]
|
if (count === 0) return palette[0]
|
||||||
if (count <= 2) return palette[1]
|
if (count <= 3) return palette[1]
|
||||||
if (count <= 4) return palette[2]
|
if (count <= 8) return palette[2]
|
||||||
if (count <= 7) return palette[3]
|
if (count <= 15) return palette[3]
|
||||||
return palette[4]
|
return palette[4]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一格未必是周日 —— 后端给的是「今天往前推 364 天」那天开始的 365 天。
|
// 一格一周,横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
|
||||||
// 原来直接拿 i % 7 当行号,等于假设首日是周日,左边 一/三/五 的标签和月份标签
|
// 365 格里三百多格空着,整张图看着像没用过
|
||||||
// 会整体错位(今天是周四就错四行)。这里按首日真实的星期做偏移。
|
|
||||||
const weekdayOffset = computed(() => {
|
|
||||||
const first = aiStore.heatmapData[0]
|
|
||||||
return first ? new Date(first.timestamp).getDay() : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const cells = computed(() =>
|
const cells = computed(() =>
|
||||||
aiStore.heatmapData.map((item, i) => {
|
aiStore.heatmapData.map((item, i) => {
|
||||||
const slot = weekdayOffset.value + i
|
const start = new Date(item.timestamp)
|
||||||
|
const endOfWeek = new Date(
|
||||||
|
start.getFullYear(),
|
||||||
|
start.getMonth(),
|
||||||
|
start.getDate() + 6,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
date: new Date(item.timestamp),
|
start,
|
||||||
|
end: endOfWeek,
|
||||||
count: item.value,
|
count: item.value,
|
||||||
color: getColor(item.value),
|
color: getColor(item.value),
|
||||||
week: Math.floor(slot / 7),
|
x: i * CELL_TOTAL,
|
||||||
day: slot % 7,
|
|
||||||
x: Math.floor(slot / 7) * CELL_TOTAL,
|
|
||||||
y: (slot % 7) * CELL_TOTAL,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -122,39 +116,30 @@ const cells = computed(() =>
|
|||||||
const monthLabels = computed(() => {
|
const monthLabels = computed(() => {
|
||||||
const labels: { text: string; x: number }[] = []
|
const labels: { text: string; x: number }[] = []
|
||||||
let lastMonth = -1
|
let lastMonth = -1
|
||||||
|
|
||||||
cells.value.forEach((cell, i) => {
|
cells.value.forEach((cell, i) => {
|
||||||
const month = cell.date.getMonth()
|
const month = cell.start.getMonth()
|
||||||
const isWeekStart = cell.day === 0 || i === 0
|
if (month !== lastMonth) {
|
||||||
|
// 第一格所在的月往往只露出小半个月,标签会和下一个月挤在一起,跳过
|
||||||
if (month !== lastMonth && (isWeekStart || cell.day <= 3)) {
|
if (i > 0 || cell.start.getDate() <= 7) {
|
||||||
labels.push({
|
labels.push({ text: `${month + 1}月`, x: cell.x })
|
||||||
text: `${month + 1}月`,
|
}
|
||||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
|
||||||
})
|
|
||||||
lastMonth = month
|
lastMonth = month
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return labels
|
return labels
|
||||||
})
|
})
|
||||||
|
|
||||||
const svgWidth = computed(() => {
|
const svgWidth = computed(
|
||||||
const last = cells.value[cells.value.length - 1]
|
() => cells.value.length * CELL_TOTAL + RIGHT_PADDING,
|
||||||
const weeks = last ? last.week + 1 : 0
|
)
|
||||||
return DAY_WIDTH + weeks * CELL_TOTAL + RIGHT_PADDING
|
const svgHeight = computed(() => MONTH_HEIGHT + CELL_HEIGHT)
|
||||||
})
|
|
||||||
|
|
||||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
|
||||||
|
|
||||||
interface Cell {
|
interface Cell {
|
||||||
date: Date
|
start: Date
|
||||||
|
end: Date
|
||||||
count: number
|
count: number
|
||||||
color: string
|
color: string
|
||||||
week: number
|
|
||||||
day: number
|
|
||||||
x: number
|
x: number
|
||||||
y: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltip = ref<{
|
const tooltip = ref<{
|
||||||
@@ -171,7 +156,7 @@ const tooltipStyle = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const getTooltipText = (count: number) =>
|
const getTooltipText = (count: number) =>
|
||||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`
|
||||||
|
|
||||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||||
@@ -181,7 +166,7 @@ const showTooltip = (e: MouseEvent, cell: Cell) => {
|
|||||||
tooltip.value = {
|
tooltip.value = {
|
||||||
x: rect.left - containerRect.left + rect.width / 2,
|
x: rect.left - containerRect.left + rect.width / 2,
|
||||||
y: rect.top - containerRect.top - 10,
|
y: rect.top - containerRect.top - 10,
|
||||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
date: `${parseTime(cell.start, "M月D日")} ~ ${parseTime(cell.end, "M月D日")}`,
|
||||||
text: getTooltipText(cell.count),
|
text: getTooltipText(cell.count),
|
||||||
count: cell.count,
|
count: cell.count,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,14 @@
|
|||||||
<Grade :grade="aiStore.detailsData.grade" />
|
<Grade :grade="aiStore.detailsData.grade" />
|
||||||
<span>{{ greeting }}</span>
|
<span>{{ greeting }}</span>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-flex vertical size="large" v-else>
|
<n-alert v-else type="error" title="你还没有完成任何题目">
|
||||||
<n-alert type="error" title="你还没有完成任何题目">
|
开始解题,看看你的学习能力吧!
|
||||||
开始解题,看看你的学习能力吧!
|
</n-alert>
|
||||||
</n-alert>
|
|
||||||
<AI />
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Grade from "./Grade.vue"
|
import Grade from "./Grade.vue"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import AI from "./AI.vue"
|
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
|
|||||||
@@ -1,277 +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="line" :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,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Colors,
|
|
||||||
Filler,
|
|
||||||
} from "chart.js"
|
|
||||||
import { useAIStore } from "oj/store/ai"
|
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import type { Grade } from "utils/types"
|
|
||||||
|
|
||||||
// 注册折线图所需的 Chart.js 组件
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Colors,
|
|
||||||
Filler,
|
|
||||||
)
|
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
|
||||||
const { chartKey } = useChartTheme()
|
|
||||||
|
|
||||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
|
||||||
const gradeColors: Record<Grade, string> = {
|
|
||||||
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
|
|
||||||
C: "#95F204",
|
|
||||||
B: "#36A2EB",
|
|
||||||
A: "#FFCE56",
|
|
||||||
S: "#FF6384",
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
return aiStore.durationData.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算累计题目数量和等级趋势
|
|
||||||
const progressData = computed(() => {
|
|
||||||
let cumulativeCount = 0
|
|
||||||
let totalWeightedGrade = 0 // 累计加权等级
|
|
||||||
let totalProblems = 0 // 累计题目总数
|
|
||||||
|
|
||||||
return aiStore.durationData.map((duration) => {
|
|
||||||
const problemCount = duration.problemCount || 0
|
|
||||||
cumulativeCount += problemCount
|
|
||||||
|
|
||||||
// 契约里空串是「无评级」(该周期没有活动),不能当成 C —— 那会在 tooltip 上
|
|
||||||
// 把一个没做题的周期写成「本期等级: C」。这里权重取 0,反正 problemCount 也是 0
|
|
||||||
const currentGradeValue = duration.grade
|
|
||||||
? gradeOrder.indexOf(duration.grade)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
// 累加加权等级
|
|
||||||
totalWeightedGrade += currentGradeValue * problemCount
|
|
||||||
totalProblems += problemCount
|
|
||||||
|
|
||||||
// 计算累计平均等级
|
|
||||||
const avgGradeValue =
|
|
||||||
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: [
|
|
||||||
parseTime(duration.start, "M月D日"),
|
|
||||||
parseTime(duration.end, "M月D日"),
|
|
||||||
].join("~"),
|
|
||||||
start: parseTime(duration.start, "YYYY-MM-DD"),
|
|
||||||
end: parseTime(duration.end, "YYYY-MM-DD"),
|
|
||||||
count: cumulativeCount,
|
|
||||||
grade: duration.grade,
|
|
||||||
gradeValue: currentGradeValue,
|
|
||||||
avgGradeValue: avgGradeValue, // 累计平均等级
|
|
||||||
problemCount: problemCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图表数据
|
|
||||||
const data = computed<ChartData<"line">>(() => {
|
|
||||||
const progress = progressData.value
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: progress.map((p) => p.label),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "累计完成题目",
|
|
||||||
data: progress.map((p) => p.count),
|
|
||||||
borderColor: "#4CAF50",
|
|
||||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
|
||||||
tension: 0.4,
|
|
||||||
yAxisID: "y",
|
|
||||||
fill: true,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
pointBackgroundColor: "#4CAF50",
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "累计平均等级",
|
|
||||||
data: progress.map((p) => p.avgGradeValue),
|
|
||||||
borderColor: "#FF9800",
|
|
||||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
|
||||||
tension: 0.4,
|
|
||||||
yAxisID: "y1",
|
|
||||||
fill: false,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图表配置
|
|
||||||
const options = computed<ChartOptions<"line">>(() => {
|
|
||||||
return {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
mode: "index",
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
autoSkip: true,
|
|
||||||
maxTicksLimit: 15,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: "linear",
|
|
||||||
position: "left",
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "累计题目数",
|
|
||||||
font: {
|
|
||||||
size: 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
stepSize: 1,
|
|
||||||
},
|
|
||||||
beginAtZero: true,
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: "linear",
|
|
||||||
position: "right",
|
|
||||||
min: -0.5,
|
|
||||||
max: gradeOrder.length - 0.5,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "累计平均等级",
|
|
||||||
font: {
|
|
||||||
size: 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
stepSize: 1,
|
|
||||||
callback: (v: string | number) => {
|
|
||||||
const idx = Math.round(Number(v))
|
|
||||||
return gradeOrder[idx] || ""
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
padding: 12,
|
|
||||||
callbacks: {
|
|
||||||
title: (items: TooltipItem<"line">[]) => {
|
|
||||||
if (items.length > 0) {
|
|
||||||
const idx = items[0].dataIndex
|
|
||||||
const progress = progressData.value[idx]
|
|
||||||
return progress ? `${progress.start} ~ ${progress.end}` : ""
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
label: (ctx: TooltipItem<"line">) => {
|
|
||||||
const dsLabel = ctx.dataset.label || ""
|
|
||||||
const idx = ctx.dataIndex
|
|
||||||
const progress = progressData.value[idx]
|
|
||||||
|
|
||||||
if (!progress) {
|
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
|
||||||
// 累计平均等级轴
|
|
||||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
|
||||||
return [
|
|
||||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
|
||||||
`本期等级: ${progress.grade || "无"}`,
|
|
||||||
`本期完成: ${progress.problemCount} 题`,
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// 累计题目数轴
|
|
||||||
return [
|
|
||||||
`${dsLabel}: ${ctx.formattedValue} 题`,
|
|
||||||
`本期完成: ${progress.problemCount} 题`,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: "bottom" as const,
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
boxHeight: 12,
|
|
||||||
padding: 8,
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.chart {
|
|
||||||
height: 300px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,136 +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">
|
|
||||||
<Pie :key="chartKey" :data="data" :options="options" />
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Pie } from "vue-chartjs"
|
|
||||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
|
||||||
import { useAIStore } from "oj/store/ai"
|
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
|
||||||
|
|
||||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
|
||||||
const { chartKey } = useChartTheme()
|
|
||||||
|
|
||||||
// 排名区间定义
|
|
||||||
const RANK_RANGES = [
|
|
||||||
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
|
|
||||||
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
|
|
||||||
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
|
|
||||||
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
|
|
||||||
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 计算每道题的排名百分位并分类
|
|
||||||
const rankDistribution = computed(() => {
|
|
||||||
const distribution = RANK_RANGES.map((range) => ({
|
|
||||||
...range,
|
|
||||||
count: 0,
|
|
||||||
problems: [] as string[],
|
|
||||||
}))
|
|
||||||
|
|
||||||
aiStore.detailsData.solved.forEach((item) => {
|
|
||||||
const rank = item.periodRank
|
|
||||||
const acCount = item.periodAcCount
|
|
||||||
|
|
||||||
if (rank && acCount && acCount > 0) {
|
|
||||||
// 口径和后端 grade() 一致:(rank - 1) / count。少减这个 1 会让每道题整体降一档,
|
|
||||||
// 而且 rank === acCount 时正好算出 100,落在 [70, 100) 之外被 findIndex 丢掉 ——
|
|
||||||
// 班里只有他一个人做出来的题(1 / 1)既被当成垫底又整个消失
|
|
||||||
const percentile = ((rank - 1) / acCount) * 100
|
|
||||||
|
|
||||||
// 找到对应的区间;万一越界也归到最后一档,别让这道题凭空不见
|
|
||||||
const rangeIndex = RANK_RANGES.findIndex(
|
|
||||||
(r) => percentile >= r.min && percentile < r.max,
|
|
||||||
)
|
|
||||||
const index = rangeIndex === -1 ? RANK_RANGES.length - 1 : rangeIndex
|
|
||||||
|
|
||||||
distribution[index].count++
|
|
||||||
distribution[index].problems.push(
|
|
||||||
`${item.problem.displayId}: ${item.problem.title}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return distribution
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = computed(() => {
|
|
||||||
return aiStore.detailsData.solved.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = computed(() => {
|
|
||||||
return {
|
|
||||||
labels: RANK_RANGES.map((r) => r.label),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "题目数量",
|
|
||||||
data: rankDistribution.value.map((r) => r.count),
|
|
||||||
backgroundColor: RANK_RANGES.map((r) => r.color),
|
|
||||||
borderColor: RANK_RANGES.map((r) => r.color),
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: "bottom" as const,
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
boxHeight: 12,
|
|
||||||
padding: 8,
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context: any) => {
|
|
||||||
const count = context.parsed
|
|
||||||
const total = rankDistribution.value.reduce(
|
|
||||||
(sum, r) => sum + r.count,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
const percentage =
|
|
||||||
total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
|
|
||||||
const label = context.label || ""
|
|
||||||
return `${label}: ${count} 道题 (${percentage}%)`
|
|
||||||
},
|
|
||||||
afterLabel: (context: any) => {
|
|
||||||
const index = context.dataIndex
|
|
||||||
const problems = rankDistribution.value[index].problems
|
|
||||||
if (problems.length > 0 && problems.length <= 5) {
|
|
||||||
return problems
|
|
||||||
} else if (problems.length > 5) {
|
|
||||||
return [
|
|
||||||
...problems.slice(0, 3),
|
|
||||||
`... 还有 ${problems.length - 3} 道题`,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
82
apps/web/src/oj/ai/components/TagsChart.vue
Normal file
82
apps/web/src/oj/ai/components/TagsChart.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<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,158 +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">
|
|
||||||
<Radar :key="chartKey" :data="data" :options="options" />
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Radar } from "vue-chartjs"
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
RadialLinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Filler,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
} from "chart.js"
|
|
||||||
import { useAIStore } from "oj/store/ai"
|
|
||||||
import { useChartTheme } from "shared/composables/chartTheme"
|
|
||||||
|
|
||||||
// 注册雷达图所需的 Chart.js 组件
|
|
||||||
ChartJS.register(
|
|
||||||
RadialLinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Filler,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
)
|
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
|
||||||
const { chartKey, gridColor } = useChartTheme()
|
|
||||||
|
|
||||||
const show = computed(() => {
|
|
||||||
return Object.keys(aiStore.detailsData.tags).length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 后端 /ai/detail 已经把 tags 截到前 5(routes/ai.ts 的 topTags),这里取同一个上限。
|
|
||||||
// 原来写 10 是取不满的死数
|
|
||||||
const MAX_TAGS = 5
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
const totalTags = Object.keys(aiStore.detailsData.tags).length
|
|
||||||
const displayTags = Math.min(totalTags, MAX_TAGS)
|
|
||||||
return `标签雷达图(前${displayTags}个)`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算归一化的数据(用于雷达图展示)
|
|
||||||
const normalizedData = computed(() => {
|
|
||||||
const tags = aiStore.detailsData.tags
|
|
||||||
|
|
||||||
// 按题目数量降序排序,取前MAX_TAGS个
|
|
||||||
const sortedTags = Object.entries(tags)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, MAX_TAGS)
|
|
||||||
|
|
||||||
const values = sortedTags.map(([, value]) => value)
|
|
||||||
const maxValue = Math.max(...values, 1) // 避免除以0
|
|
||||||
|
|
||||||
// 归一化到0-100的范围
|
|
||||||
return sortedTags.map(([label, value]) => ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
normalized: (value / maxValue) * 100,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = computed(() => {
|
|
||||||
const tagData = normalizedData.value
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: tagData.map((item) => item.label),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "掌握程度",
|
|
||||||
data: tagData.map((item) => item.normalized),
|
|
||||||
backgroundColor: "rgba(99, 102, 241, 0.25)",
|
|
||||||
borderColor: "rgb(99, 102, 241)",
|
|
||||||
borderWidth: 2.5,
|
|
||||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointHoverBackgroundColor: "#fff",
|
|
||||||
pointHoverBorderColor: "rgb(99, 102, 241)",
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
const tagData = normalizedData.value
|
|
||||||
|
|
||||||
return {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
r: {
|
|
||||||
beginAtZero: true,
|
|
||||||
max: 100,
|
|
||||||
min: 0,
|
|
||||||
ticks: {
|
|
||||||
stepSize: 20,
|
|
||||||
backdropColor: "transparent",
|
|
||||||
callback: function (value: string | number) {
|
|
||||||
return Number(value) + "%"
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
size: 11,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: gridColor.value,
|
|
||||||
circular: true,
|
|
||||||
},
|
|
||||||
angleLines: {
|
|
||||||
color: gridColor.value,
|
|
||||||
},
|
|
||||||
pointLabels: {
|
|
||||||
font: {
|
|
||||||
size: 13,
|
|
||||||
weight: 500 as const,
|
|
||||||
},
|
|
||||||
padding: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
callbacks: {
|
|
||||||
label: function (context: any) {
|
|
||||||
const index = context.dataIndex
|
|
||||||
const actualValue = tagData[index].value
|
|
||||||
const percentage = Math.round(Number(context.parsed.r))
|
|
||||||
// 这个百分比是「占最多的那个标签的比例」,不是掌握度 —— 第一名恒为 100%
|
|
||||||
return `完成 ${actualValue} 道题 (占最多标签的 ${percentage}%)`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.chart {
|
|
||||||
height: 300px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<n-card title="时间活跃度分析" size="small" v-if="show">
|
<n-card title="时间活跃度分析" size="small" v-if="show">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px">
|
<n-text depth="3" style="font-size: 12px">
|
||||||
基于 AC 时间,发现解题高峰时段
|
基于全部提交,发现做题高峰时段
|
||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<div style="height: 300px">
|
<div style="height: 300px">
|
||||||
@@ -38,39 +38,27 @@ const TIME_PERIODS = [
|
|||||||
{ label: "晚上(18-24)", start: 18, end: 24 },
|
{ label: "晚上(18-24)", start: 18, end: 24 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 统计每个星期几和时间段的做题数量
|
// 直接用后端聚合好的 activity(按全部提交、按东八区切好的 7×4)。
|
||||||
|
// 原来是拿 solved 里的 acTime 在浏览器本地时区现算 —— 只统计 AC,
|
||||||
|
// 28 个格子里能落进去的点太少;时区也和后端的热力图对不上
|
||||||
const activityMatrix = computed(() => {
|
const activityMatrix = computed(() => {
|
||||||
const matrix: { [weekday: number]: { [period: number]: number } } = {}
|
const matrix: { [weekday: number]: { [period: number]: number } } = {}
|
||||||
|
|
||||||
// 初始化矩阵
|
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
matrix[i] = {}
|
matrix[i] = {}
|
||||||
for (let j = 0; j < TIME_PERIODS.length; j++) {
|
for (let j = 0; j < TIME_PERIODS.length; j++) {
|
||||||
matrix[i][j] = 0
|
matrix[i][j] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
aiStore.detailsData.activity.forEach((item) => {
|
||||||
// 统计数据
|
const row = matrix[item.weekday]
|
||||||
aiStore.detailsData.solved.forEach((item) => {
|
if (row && item.period in row) row[item.period] += item.count
|
||||||
const date = new Date(item.acTime)
|
|
||||||
const weekday = date.getDay() // 0-6,0是周日
|
|
||||||
const hour = date.getHours() // 0-23
|
|
||||||
|
|
||||||
// 找到对应的时间段
|
|
||||||
const periodIndex = TIME_PERIODS.findIndex(
|
|
||||||
(p) => hour >= p.start && hour < p.end,
|
|
||||||
)
|
|
||||||
if (periodIndex !== -1) {
|
|
||||||
matrix[weekday][periodIndex]++
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return matrix
|
return matrix
|
||||||
})
|
})
|
||||||
|
|
||||||
const show = computed(() => {
|
const show = computed(() =>
|
||||||
return aiStore.detailsData.solved.length > 0
|
aiStore.detailsData.activity.some((item) => item.count > 0),
|
||||||
})
|
)
|
||||||
|
|
||||||
// 为每个时间段准备数据集
|
// 为每个时间段准备数据集
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
@@ -124,7 +112,7 @@ const options = {
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: "完成题目数",
|
text: "提交次数",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -147,7 +135,7 @@ const options = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
footer: (items: any[]) => {
|
footer: (items: any[]) => {
|
||||||
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
||||||
return `当天总计: ${total} 题`
|
return `当天总计: ${total} 次提交`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
contestCount: 0,
|
contestCount: 0,
|
||||||
solved: [],
|
solved: [],
|
||||||
flowcharts: [],
|
flowcharts: [],
|
||||||
|
activity: [],
|
||||||
|
errors: [],
|
||||||
rankScope: "global",
|
rankScope: "global",
|
||||||
})
|
})
|
||||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
@@ -52,6 +54,8 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
detailsData.tags = res.tags
|
detailsData.tags = res.tags
|
||||||
detailsData.difficulty = res.difficulty
|
detailsData.difficulty = res.difficulty
|
||||||
detailsData.contestCount = res.contestCount
|
detailsData.contestCount = res.contestCount
|
||||||
|
detailsData.activity = res.activity
|
||||||
|
detailsData.errors = res.errors
|
||||||
detailsData.rankScope = res.rankScope
|
detailsData.rankScope = res.rankScope
|
||||||
detailsData.flowcharts = res.flowcharts
|
detailsData.flowcharts = res.flowcharts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const solvedProblemSchema = z.object({
|
|||||||
periodRank: z.number().int().nullable(),
|
periodRank: z.number().int().nullable(),
|
||||||
periodAcCount: z.number().int(),
|
periodAcCount: z.number().int(),
|
||||||
difficulty: z.string(),
|
difficulty: z.string(),
|
||||||
|
/** 到首次通过为止在这道题上提交了几次(含通过那次)。1 就是一次过 */
|
||||||
|
attempts: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowchartSummarySchema = z.object({
|
export const flowchartSummarySchema = z.object({
|
||||||
@@ -44,6 +46,18 @@ export const flowchartSummarySchema = z.object({
|
|||||||
avgScore: z.number(),
|
avgScore: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间活跃度的一个格子。星期和时段都按东八区切,和热力图同一个口径 ——
|
||||||
|
* 别让容器或数据库的 TZ 决定「学生周几晚上做题多」。
|
||||||
|
*/
|
||||||
|
export const activityBucketSchema = z.object({
|
||||||
|
/** 0=周日 … 6=周六 */
|
||||||
|
weekday: z.number().int().min(0).max(6),
|
||||||
|
/** 0=凌晨(0-6) 1=上午(6-12) 2=下午(12-18) 3=晚上(18-24) */
|
||||||
|
period: z.number().int().min(0).max(3),
|
||||||
|
count: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
export const aiDetailSchema = z.object({
|
export const aiDetailSchema = z.object({
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
className: z.string().nullable(),
|
className: z.string().nullable(),
|
||||||
@@ -55,6 +69,16 @@ export const aiDetailSchema = z.object({
|
|||||||
tags: z.record(z.string(), z.number().int()),
|
tags: z.record(z.string(), z.number().int()),
|
||||||
difficulty: z.record(z.string(), z.number().int()),
|
difficulty: z.record(z.string(), z.number().int()),
|
||||||
contestCount: z.number().int(),
|
contestCount: z.number().int(),
|
||||||
|
/** 时间活跃度:按**全部提交**统计,不是只统计 AC */
|
||||||
|
activity: z.array(activityBucketSchema),
|
||||||
|
/**
|
||||||
|
* 判完的失败提交按状态码分组,多的在前。状态码是落库的值,
|
||||||
|
* 前端用 utils/constants 的 JUDGE_STATUS 翻成中文,两边必须一致。
|
||||||
|
*/
|
||||||
|
errors: z.array(z.object({
|
||||||
|
result: z.number().int(),
|
||||||
|
count: z.number().int(),
|
||||||
|
})),
|
||||||
/**
|
/**
|
||||||
* solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服,
|
* solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服,
|
||||||
* 前端不能只看 className 有没有值就写「班级排名」。
|
* 前端不能只看 className 有没有值就写「班级排名」。
|
||||||
@@ -85,6 +109,11 @@ export const classPkAnalysisRequestSchema = z.object({
|
|||||||
timeRangeLabel: z.string().default("全部时间"),
|
timeRangeLabel: z.string().default("全部时间"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 热力图的一格 = **一周**(不是一天)。timestamp 是那一周周一的本地零点,
|
||||||
|
* value 是整周的提交次数。按天切的话一年 365 格里三百多格是空的,
|
||||||
|
* 中职学生一年也就在二三十天有提交,整张图看着像没用过。
|
||||||
|
*/
|
||||||
export const heatmapItemSchema = z.object({
|
export const heatmapItemSchema = z.object({
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
value: z.number().int(),
|
value: z.number().int(),
|
||||||
@@ -119,6 +148,7 @@ export type Grade = z.infer<typeof gradeSchema>
|
|||||||
export type DurationData = z.infer<typeof durationDataSchema>
|
export type DurationData = z.infer<typeof durationDataSchema>
|
||||||
export type SolvedProblem = z.infer<typeof solvedProblemSchema>
|
export type SolvedProblem = z.infer<typeof solvedProblemSchema>
|
||||||
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
||||||
|
export type ActivityBucket = z.infer<typeof activityBucketSchema>
|
||||||
export type AiDetail = z.infer<typeof aiDetailSchema>
|
export type AiDetail = z.infer<typeof aiDetailSchema>
|
||||||
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
||||||
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user