feat(激励): 知识点地图——每个知识点做对几道、在哪一档、再做几道升级,首页一句话提示本周进步
Deploy / deploy (push) Canceled after 0s

现有 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:
xuyueandClaude Opus 5.5 committed 2026-09-27 23:17:43 +08:00
1 parent a63bf6ea07
commit a7f6ebaf49
8 files changed
+446

No files matched your search

@@ -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>
+11
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import KnowledgeMap from "./components/KnowledgeMap.vue"
import { Icon } from "@iconify/vue"
import { getProfile } from "shared/api"
import { durationToDays, parseTime } from "utils/functions"
@@ -11,6 +12,15 @@ import { useUserStore } from "shared/store/user"
const route = useRoute()
const router = useRouter()
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 problems = ref<string[]>([])
const firstSubmissionAt = ref("")
@@ -219,6 +229,7 @@ onMounted(() => {
</n-flex>
</n-card>
<KnowledgeMap v-if="!loading && profile && isSelf" />
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length">
<template #label>
+72
View File
@@ -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
}