From a7f6ebaf4991644a216210af3c67d9d24fda102f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 23:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=BF=80=E5=8A=B1):=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E7=82=B9=E5=9C=B0=E5=9B=BE=E2=80=94=E2=80=94=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E7=82=B9=E5=81=9A=E5=AF=B9=E5=87=A0=E9=81=93?= =?UTF-8?q?=E3=80=81=E5=9C=A8=E5=93=AA=E4=B8=80=E6=A1=A3=E3=80=81=E5=86=8D?= =?UTF-8?q?=E5=81=9A=E5=87=A0=E9=81=93=E5=8D=87=E7=BA=A7=EF=BC=8C=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E4=B8=80=E5=8F=A5=E8=AF=9D=E6=8F=90=E7=A4=BA=E6=9C=AC?= =?UTF-8?q?=E5=91=A8=E8=BF=9B=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有 38 个成就全是累计量(通过几题、提交几次、连续几天),起步的人人都有、往上的够不着, 而且没有一个在说「你学会了什么」。连续打卡在这里结构上做不到:2025 秋学生最长连续交题 天数的中位数是 1 天,连续 7 天的只有 3 人。 - GET /me/knowledge:题数 ≥10 的知识点标签(10 个),每个给做对几道、档位、周一时的档位、 下一档门槛;四档 入门 1 / 会了 3 / 熟练 题数 40%(≤10)/ 精通 75%(≤20)。 回测 2025 秋:中位数学生一学期升 5 档;「入门」一档是给只做对一两道的 71 人加的 - 同班 ≥30% 点亮过的知识点才会出现在「还没碰过」里,免得 Python 班看到「C 语言」 - 个人主页加「我的知识点」,只在看自己时显示;首页一句话:本周升级 > 离下一档最近的 > 去点亮一个班里在学的,点了去按知识点筛的题目列表 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/account.ts | 155 ++++++++++++++++++ apps/web/src/oj/api.ts | 6 + apps/web/src/oj/home/dashboard.vue | 31 ++++ .../src/oj/user/components/KnowledgeMap.vue | 135 +++++++++++++++ apps/web/src/oj/user/index.vue | 11 ++ apps/web/src/oj/user/knowledge.ts | 72 ++++++++ apps/web/src/utils/types.ts | 3 + packages/contract/src/account.ts | 33 ++++ 8 files changed, 446 insertions(+) create mode 100644 apps/web/src/oj/user/components/KnowledgeMap.vue create mode 100644 apps/web/src/oj/user/knowledge.ts diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index ec27007..18c5cc5 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -6,6 +6,8 @@ import { STUDENT_ROLES, updateProfileRequestSchema, type ActivityRankItem, + type KnowledgeLevel, + type KnowledgeMap, type Metrics, type MyRank, type ProblemRank, @@ -499,6 +501,159 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => { } satisfies WeeklyRank) }) +/** 题数少于这个的知识点不参与升级:3 道题的知识点做完就「精通」,没有意义 */ +const KNOWLEDGE_MIN_PROBLEMS = 10 +/** 同班这么多比例的人点亮过,才算「班里在学」,才会出现在「还没碰过」里 */ +const KNOWLEDGE_CLASS_TOUCHED_RATIO = 0.3 + +/** + * 四档门槛:入门 1 道、会了 3 道、熟练 = 题数的 40%(最多 10)、精通 = 75%(最多 20)。 + * 按题量缩放是因为知识点之间差得很远(循环结构 107 道、数组 15 道)。 + * + * 拿 2025 秋回测过:中位数的学生一学期升 5 档,前 25% 升 9 档;加上「入门」这一档之前, + * 26% 的学生一档都没升,其中一半做对过带标签的题、只是没到 3 道。 + */ +function levelThresholds(problemCount: number) { + return [ + 1, + 3, + Math.max(4, Math.min(10, Math.round(problemCount * 0.4))), + Math.max(5, Math.min(20, Math.round(problemCount * 0.75))), + ] +} + +function levelOf(solved: number, thresholds: number[]) { + return thresholds.filter((threshold) => solved >= threshold).length +} + +/** + * 我的知识点地图。「做对」按题目算(同一题只算一次),不含比赛提交, + * 题目只算公开的题库题 —— 和 problemCount 同一个口径,不然会出现 12/10。 + */ +accountRoutes.get("/me/knowledge", requireAuth, async (c) => { + const user = c.get("user")! + const tags = await db + .select({ + id: schema.problemTag.id, + name: schema.problemTag.name, + problemCount: 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.id, schema.problemTag.name) + .having(sql`count(*) >= ${KNOWLEDGE_MIN_PROBLEMS}`) + const tagIds = tags.map((tag) => tag.id) + if (!tagIds.length) return success(c, { tags: [], classTouched: [] } satisfies KnowledgeMap) + + const firstAccepted = db + .select({ + problemId: schema.submission.problemId, + firstAt: min(schema.submission.createTime).as("first_at"), + }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + isNull(schema.submission.contestId), + inArray(schema.submission.result, ACCEPTED_RESULTS), + ), + ) + .groupBy(schema.submission.problemId) + .as("first_accepted") + + const start = weekStart() + const [mine, classTouchedRows, classSize] = await Promise.all([ + db + .select({ + tagId: schema.problemTags.problemtagId, + solved: count(), + solvedBeforeWeek: sql`count(*) filter (where ${firstAccepted.firstAt} < ${start})::int`, + }) + .from(firstAccepted) + .innerJoin(schema.problemTags, eq(schema.problemTags.problemId, firstAccepted.problemId)) + .innerJoin(schema.problem, eq(schema.problem.id, firstAccepted.problemId)) + .where( + and( + inArray(schema.problemTags.problemtagId, tagIds), + eq(schema.problem.visible, true), + isNull(schema.problem.contestId), + ), + ) + .groupBy(schema.problemTags.problemtagId), + user.className + ? db + .select({ + tagId: schema.problemTags.problemtagId, + users: countDistinct(schema.submission.userId), + }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .innerJoin( + schema.problemTags, + eq(schema.problemTags.problemId, schema.submission.problemId), + ) + .where( + and( + eq(schema.user.className, user.className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + isNull(schema.submission.contestId), + inArray(schema.submission.result, ACCEPTED_RESULTS), + inArray(schema.problemTags.problemtagId, tagIds), + ), + ) + .groupBy(schema.problemTags.problemtagId) + : [], + user.className + ? db + .select({ total: count() }) + .from(schema.user) + .where( + and( + eq(schema.user.className, user.className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + ), + ) + : [], + ]) + + const mineByTag = new Map(mine.map((row) => [row.tagId, row])) + const total = classSize[0]?.total ?? 0 + const touchedIds = new Set( + classTouchedRows + .filter((row) => total > 0 && row.users / total >= KNOWLEDGE_CLASS_TOUCHED_RATIO) + .map((row) => row.tagId), + ) + + return success(c, { + tags: tags + .sort((a, b) => b.problemCount - a.problemCount) + .map((tag) => { + const thresholds = levelThresholds(tag.problemCount) + const solved = mineByTag.get(tag.id)?.solved ?? 0 + const level = levelOf(solved, thresholds) + return { + name: tag.name, + problemCount: tag.problemCount, + solved, + level, + levelAtWeekStart: levelOf(mineByTag.get(tag.id)?.solvedBeforeWeek ?? 0, thresholds), + nextAt: thresholds[level] ?? null, + } satisfies KnowledgeLevel + }), + classTouched: tags.filter((tag) => touchedIds.has(tag.id)).map((tag) => tag.name), + } satisfies KnowledgeMap) +}) + accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { const user = c.get("user")! const [problem] = await db diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 8b5f041..f902f08 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -10,6 +10,7 @@ import { type TutorialSummary, type ClassActivity, type ClassBoard, + type KnowledgeMap, type ClassComparisonResponse, type ClassRankItem, type ClassUserRank, @@ -228,6 +229,11 @@ export function setClassLesson(className: string, problemDisplayIds: string[]) { return api.put("classroom/lesson", { className, problemDisplayIds }) } +/** 我的知识点地图(只有自己的,不能查别人) */ +export function getKnowledgeMap() { + return api.get("me/knowledge") +} + export function getClassActivity() { return api.get("me/class-activity") } diff --git a/apps/web/src/oj/home/dashboard.vue b/apps/web/src/oj/home/dashboard.vue index 98f379b..37853d6 100644 --- a/apps/web/src/oj/home/dashboard.vue +++ b/apps/web/src/oj/home/dashboard.vue @@ -4,6 +4,7 @@ import { getAnnouncement, getAnnouncementList, getClassActivity, + getKnowledgeMap, getContestList, getLearnProgress, getSubmissions, @@ -13,6 +14,7 @@ import { import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useBreakpoints } from "shared/composables/breakpoints" import { useProblemJump } from "shared/composables/problemJump" +import { pickHeadline, type Headline } from "oj/user/knowledge" import { useUserStore } from "shared/store/user" import { ContestStatus, CONTEST_STATUS } from "utils/constants" import { duration, parseTime, zonedParts } from "utils/functions" @@ -49,6 +51,8 @@ const submissions = ref([]) const announcements = ref([]) const weekly = ref(null) const classActivity = ref(null) +/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */ +const knowledgeHeadline = ref(null) const loaded = ref(false) const keyword = ref("") @@ -121,6 +125,10 @@ async function loadAnnouncements() { announcements.value = res.results } +async function loadKnowledge() { + knowledgeHeadline.value = pickHeadline(await getKnowledgeMap()) +} + async function loadClassActivity() { classActivity.value = await getClassActivity() } @@ -160,6 +168,7 @@ async function load() { loadAnnouncements(), loadWeekly(), loadClassActivity(), + loadKnowledge(), ]) loaded.value = true } @@ -226,6 +235,13 @@ async function openAnnouncement(item: AnnouncementListItem) { {{ weeklyText }} +
+ + {{ knowledgeHeadline.text }} + + {{ knowledgeHeadline.text }} + 我的知识点 +
+import { getKnowledgeMap } from "oj/api" +import { levelName, MAX_LEVEL, remainingToNext, tagLink } from "oj/user/knowledge" +import type { KnowledgeLevel, KnowledgeMap } from "utils/types" + +/** + * 个人主页上的知识点地图:「我学会了什么」,只和自己比。 + * + * 只在看**自己的**主页时挂(index.vue 判断),不开放给别人看 —— 开放了它就成了 + * 另一张排行榜,基础弱的学生最不想被看到的就是这个。 + */ +const map = ref(null) + +onMounted(async () => { + map.value = await getKnowledgeMap().catch(() => null) +}) + +const learned = computed(() => + (map.value?.tags ?? []) + .filter((tag) => tag.level >= 1) + .sort((a, b) => b.level - a.level || b.solved - a.solved), +) + +/** 班里同学在学、自己还没碰过的。别的(比如 Python 班的「C 语言」)不列 */ +const untouched = computed(() => { + const touched = new Set(map.value?.classTouched ?? []) + return (map.value?.tags ?? []).filter((tag) => tag.level === 0 && touched.has(tag.name)) +}) + +function progress(tag: KnowledgeLevel) { + if (tag.nextAt === null) return 100 + return Math.min(100, Math.round((tag.solved / tag.nextAt) * 100)) +} + +function hint(tag: KnowledgeLevel) { + const left = remainingToNext(tag) + if (left === null) return `做对了 ${tag.solved} 道,已经精通了` + return `做对 ${tag.solved} 道 · 再做 ${left} 道到「${levelName(tag.level + 1)}」` +} + + + + + diff --git a/apps/web/src/oj/user/index.vue b/apps/web/src/oj/user/index.vue index 8732e37..d481242 100644 --- a/apps/web/src/oj/user/index.vue +++ b/apps/web/src/oj/user/index.vue @@ -1,4 +1,5 @@