现有 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a63bf6ea07
commit
a7f6ebaf49
8 files changed
+446
No files matched your search
@@ -6,6 +6,8 @@ import {
|
|||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
updateProfileRequestSchema,
|
updateProfileRequestSchema,
|
||||||
type ActivityRankItem,
|
type ActivityRankItem,
|
||||||
|
type KnowledgeLevel,
|
||||||
|
type KnowledgeMap,
|
||||||
type Metrics,
|
type Metrics,
|
||||||
type MyRank,
|
type MyRank,
|
||||||
type ProblemRank,
|
type ProblemRank,
|
||||||
@@ -499,6 +501,159 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
|
|||||||
} satisfies WeeklyRank)
|
} 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<number>`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) => {
|
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problem] = await db
|
const [problem] = await db
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type TutorialSummary,
|
type TutorialSummary,
|
||||||
type ClassActivity,
|
type ClassActivity,
|
||||||
type ClassBoard,
|
type ClassBoard,
|
||||||
|
type KnowledgeMap,
|
||||||
type ClassComparisonResponse,
|
type ClassComparisonResponse,
|
||||||
type ClassRankItem,
|
type ClassRankItem,
|
||||||
type ClassUserRank,
|
type ClassUserRank,
|
||||||
@@ -228,6 +229,11 @@ export function setClassLesson(className: string, problemDisplayIds: string[]) {
|
|||||||
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
|
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 我的知识点地图(只有自己的,不能查别人) */
|
||||||
|
export function getKnowledgeMap() {
|
||||||
|
return api.get<KnowledgeMap>("me/knowledge")
|
||||||
|
}
|
||||||
|
|
||||||
export function getClassActivity() {
|
export function getClassActivity() {
|
||||||
return api.get<ClassActivity>("me/class-activity")
|
return api.get<ClassActivity>("me/class-activity")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getAnnouncement,
|
getAnnouncement,
|
||||||
getAnnouncementList,
|
getAnnouncementList,
|
||||||
getClassActivity,
|
getClassActivity,
|
||||||
|
getKnowledgeMap,
|
||||||
getContestList,
|
getContestList,
|
||||||
getLearnProgress,
|
getLearnProgress,
|
||||||
getSubmissions,
|
getSubmissions,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useProblemJump } from "shared/composables/problemJump"
|
import { useProblemJump } from "shared/composables/problemJump"
|
||||||
|
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, CONTEST_STATUS } from "utils/constants"
|
import { ContestStatus, CONTEST_STATUS } from "utils/constants"
|
||||||
import { duration, parseTime, zonedParts } from "utils/functions"
|
import { duration, parseTime, zonedParts } from "utils/functions"
|
||||||
@@ -49,6 +51,8 @@ const submissions = ref<SubmissionListItem[]>([])
|
|||||||
const announcements = ref<AnnouncementListItem[]>([])
|
const announcements = ref<AnnouncementListItem[]>([])
|
||||||
const weekly = ref<WeeklyRank | null>(null)
|
const weekly = ref<WeeklyRank | null>(null)
|
||||||
const classActivity = ref<ClassActivity | null>(null)
|
const classActivity = ref<ClassActivity | null>(null)
|
||||||
|
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
||||||
|
const knowledgeHeadline = ref<Headline | null>(null)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const keyword = ref("")
|
const keyword = ref("")
|
||||||
|
|
||||||
@@ -121,6 +125,10 @@ async function loadAnnouncements() {
|
|||||||
announcements.value = res.results
|
announcements.value = res.results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadKnowledge() {
|
||||||
|
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
|
||||||
|
}
|
||||||
|
|
||||||
async function loadClassActivity() {
|
async function loadClassActivity() {
|
||||||
classActivity.value = await getClassActivity()
|
classActivity.value = await getClassActivity()
|
||||||
}
|
}
|
||||||
@@ -160,6 +168,7 @@ async function load() {
|
|||||||
loadAnnouncements(),
|
loadAnnouncements(),
|
||||||
loadWeekly(),
|
loadWeekly(),
|
||||||
loadClassActivity(),
|
loadClassActivity(),
|
||||||
|
loadKnowledge(),
|
||||||
])
|
])
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
@@ -226,6 +235,13 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
||||||
</template>
|
</template>
|
||||||
</n-text>
|
</n-text>
|
||||||
|
<div v-if="knowledgeHeadline" class="knowledge-line">
|
||||||
|
<router-link v-if="knowledgeHeadline.to" :to="knowledgeHeadline.to" class="plain-link">
|
||||||
|
{{ knowledgeHeadline.text }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>{{ knowledgeHeadline.text }}</span>
|
||||||
|
<router-link to="/user" class="more">我的知识点</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-input-group class="search">
|
<n-input-group class="search">
|
||||||
<n-input
|
<n-input
|
||||||
@@ -421,6 +437,21 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-line {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 这一行本身就是链接(去按知识点筛的题目列表),下划线说明它能点 */
|
||||||
|
.knowledge-line .plain-link {
|
||||||
|
color: #18a058;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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<KnowledgeMap | null>(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)}」`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card v-if="map && (learned.length || untouched.length)" class="knowledge" title="我的知识点">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-text depth="3" class="meta">入门 → 会了 → 熟练 → 精通</n-text>
|
||||||
|
</template>
|
||||||
|
<n-flex vertical :size="14">
|
||||||
|
<router-link v-for="tag in learned" :key="tag.name" :to="tagLink(tag.name)" class="row">
|
||||||
|
<div class="head">
|
||||||
|
<span class="name">{{ tag.name }}</span>
|
||||||
|
<span class="dots" :title="levelName(tag.level)">
|
||||||
|
<span v-for="i in MAX_LEVEL" :key="i" class="dot" :class="{ on: i <= tag.level }" />
|
||||||
|
</span>
|
||||||
|
<span class="level">{{ levelName(tag.level) }}</span>
|
||||||
|
<n-tag
|
||||||
|
v-if="tag.level > tag.levelAtWeekStart"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
本周升级
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="progress(tag)"
|
||||||
|
:show-indicator="false"
|
||||||
|
:height="6"
|
||||||
|
status="success"
|
||||||
|
/>
|
||||||
|
<n-text depth="3" class="meta">{{ hint(tag) }}</n-text>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<div v-if="untouched.length">
|
||||||
|
<n-text depth="3" class="meta">班里同学在学、你还没碰过:</n-text>
|
||||||
|
<n-flex :size="8" style="margin-top: 6px">
|
||||||
|
<router-link v-for="tag in untouched" :key="tag.name" :to="tagLink(tag.name)">
|
||||||
|
<n-tag size="small">{{ tag.name }}</n-tag>
|
||||||
|
</router-link>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge {
|
||||||
|
max-width: 610px;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(128, 128, 128, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on {
|
||||||
|
background-color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import KnowledgeMap from "./components/KnowledgeMap.vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { getProfile } from "shared/api"
|
import { getProfile } from "shared/api"
|
||||||
import { durationToDays, parseTime } from "utils/functions"
|
import { durationToDays, parseTime } from "utils/functions"
|
||||||
@@ -11,6 +12,15 @@ import { useUserStore } from "shared/store/user"
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在看自己的主页。知识点地图只在这时候挂:它不给别人看(见 KnowledgeMap.vue),
|
||||||
|
* 后端 /me/knowledge 也只认当前登录的人。带 ?name= 看的是别人,除非名字就是自己。
|
||||||
|
*/
|
||||||
|
const isSelf = computed(() => {
|
||||||
|
const name = route.query.name as string | undefined
|
||||||
|
return !name || name === userStore.user?.username
|
||||||
|
})
|
||||||
const profile = ref<Profile | null>(null)
|
const profile = ref<Profile | null>(null)
|
||||||
const problems = ref<string[]>([])
|
const problems = ref<string[]>([])
|
||||||
const firstSubmissionAt = ref("")
|
const firstSubmissionAt = ref("")
|
||||||
@@ -219,6 +229,7 @@ onMounted(() => {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
|
<KnowledgeMap v-if="!loading && profile && isSelf" />
|
||||||
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
|
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
|
||||||
<n-descriptions-item v-if="!!problems.length">
|
<n-descriptions-item v-if="!!problems.length">
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { KnowledgeLevel, KnowledgeMap } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识点地图的档位名(后端 routes/account.ts 的 levelThresholds 定门槛)。
|
||||||
|
* 用「会了」「熟练」这种学生听得懂的词,不用「Lv.3」—— 这张图要说的是「我会了什么」,
|
||||||
|
* 不是又一套积分。
|
||||||
|
*/
|
||||||
|
export const LEVEL_NAMES = ["没碰过", "入门", "会了", "熟练", "精通"] as const
|
||||||
|
|
||||||
|
export const MAX_LEVEL = LEVEL_NAMES.length - 1
|
||||||
|
|
||||||
|
export function levelName(level: number) {
|
||||||
|
return LEVEL_NAMES[Math.max(0, Math.min(MAX_LEVEL, level))]!
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 离下一档还差几道;已经最高档为 null */
|
||||||
|
export function remainingToNext(tag: KnowledgeLevel) {
|
||||||
|
return tag.nextAt === null ? null : Math.max(1, tag.nextAt - tag.solved)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagLink(name: string) {
|
||||||
|
return { path: "/problem", query: { tag: name } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Headline {
|
||||||
|
text: string
|
||||||
|
/** 点了去哪:按这个知识点筛的题目列表 */
|
||||||
|
to?: ReturnType<typeof tagLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页「你好」下面那一行:一次只说一件事,挑最能让人想再做一道的那件。
|
||||||
|
*
|
||||||
|
* 1. 本周升级了 —— 刚发生的进步,最值得说
|
||||||
|
* 2. 正在学的里面,离下一档最近的那个(班里在学的优先)—— 给一个很近的目标
|
||||||
|
* 3. 一个都没点亮:挑一个班里同学在学的,让他去点亮
|
||||||
|
*/
|
||||||
|
export function pickHeadline(map: KnowledgeMap): Headline | null {
|
||||||
|
const upgraded = map.tags
|
||||||
|
.filter((tag) => tag.level > tag.levelAtWeekStart)
|
||||||
|
.sort((a, b) => b.level - a.level)
|
||||||
|
if (upgraded.length === 1) {
|
||||||
|
const tag = upgraded[0]!
|
||||||
|
return { text: `本周升级:${tag.name} → ${levelName(tag.level)}`, to: tagLink(tag.name) }
|
||||||
|
}
|
||||||
|
if (upgraded.length > 1) {
|
||||||
|
const list = upgraded.map((tag) => `${tag.name}「${levelName(tag.level)}」`).join("、")
|
||||||
|
return { text: `本周升级了 ${upgraded.length} 个知识点:${list}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const touched = new Set(map.classTouched)
|
||||||
|
const learning = map.tags
|
||||||
|
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(touched.has(b.name)) - Number(touched.has(a.name)) ||
|
||||||
|
remainingToNext(a)! - remainingToNext(b)!,
|
||||||
|
)
|
||||||
|
const next = learning[0]
|
||||||
|
if (next) {
|
||||||
|
return {
|
||||||
|
text: `正在学${next.name},再做 ${remainingToNext(next)} 道就到「${levelName(next.level + 1)}」`,
|
||||||
|
to: tagLink(next.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
|
||||||
|
if (fresh) {
|
||||||
|
return { text: `班里同学在学${fresh.name},去做一道点亮它`, to: tagLink(fresh.name) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -293,6 +293,9 @@ export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
|
|||||||
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
|
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
|
||||||
export type { ClassBoard, ClassBoardStudent, ClassBoardCell } from "@oj2/contract"
|
export type { ClassBoard, ClassBoardStudent, ClassBoardCell } from "@oj2/contract"
|
||||||
|
|
||||||
|
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
|
||||||
|
export type { KnowledgeLevel, KnowledgeMap } from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
|
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
|
||||||
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)
|
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)
|
||||||
|
|||||||
@@ -107,3 +107,36 @@ export type WeeklyRank = z.infer<typeof weeklyRankSchema>
|
|||||||
export type Metrics = z.infer<typeof metricsSchema>
|
export type Metrics = z.infer<typeof metricsSchema>
|
||||||
|
|
||||||
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识点地图:每个知识点做对了几道、在第几档。只给自己看(`/me/knowledge`),不出排名。
|
||||||
|
*
|
||||||
|
* 档位 0–4:0 没碰过、1 入门(做对 1 道)、2 会了(3 道)、3 熟练、4 精通,后两档的
|
||||||
|
* 门槛按这个知识点的题量缩放(见 routes/account.ts 的 levelThresholds)。「入门」那一档
|
||||||
|
* 是为「只做对一两道」的学生加的:2025 秋一学期一档都没升的 151 人里,71 人做对过带
|
||||||
|
* 标签的题,只是没到 3 道,只看结果的档位设计下他们什么都看不到。
|
||||||
|
*/
|
||||||
|
export const knowledgeLevelSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
/** 这个知识点下公开的题库题数(不含比赛题) */
|
||||||
|
problemCount: z.number().int(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
level: z.number().int(),
|
||||||
|
/** 本周一 0 点时的档位,和 level 不同就是「本周升级了」 */
|
||||||
|
levelAtWeekStart: z.number().int(),
|
||||||
|
/** 升到下一档要累计做对几道(总数,不是差值);已经是最高档为 null */
|
||||||
|
nextAt: z.number().int().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const knowledgeMapSchema = z.object({
|
||||||
|
/** 题数 ≥ 10 的知识点,按题数从多到少 */
|
||||||
|
tags: z.array(knowledgeLevelSchema),
|
||||||
|
/**
|
||||||
|
* 同班 30% 以上的同学点亮过的知识点。「还没碰过」只从这里挑 —— 不然学 Python 的班
|
||||||
|
* 会看到「C 语言:还没碰过」。没有班级时为空。
|
||||||
|
*/
|
||||||
|
classTouched: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type KnowledgeLevel = z.infer<typeof knowledgeLevelSchema>
|
||||||
|
export type KnowledgeMap = z.infer<typeof knowledgeMapSchema>
|
||||||
Reference in new issue
Block a user