feat(自学): 重做学生端页面与教师端学情概览
Deploy / deploy (push) Has been cancelled

学生端:目录 + 居中限宽正文 + 可收起的示例代码栏;目录改成三态圆点,
顶部加总进度;上一课/下一课栏桌面端固定在底部。
教师端:汇总卡片、学生状态标签与筛选(未开始/7 天没学/只读不练等)、
最后学习补「N 天前」、按练习表加「没人一次对/多数人卡住」提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Sonnet 5 committed 2026-09-19 05:07:37 -06:00
1 parent c228b164cf
commit 20a6ddc79c
7 files changed
+498 -188

No files matched your search

+101 -58
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
defineProps<{
const props = defineProps<{
titles: { id: number; title: string }[]
step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */
@@ -14,70 +13,114 @@ defineProps<{
const emit = defineEmits<{ select: [lesson: number] }>()
// 打开过但一秒都没攒够时 readableDuration 给的是 "-",「读了 -」不像人话。
// 心跳 15 秒一跳,点开就走确实会落在 0 上
function readSoFar(seconds: number) {
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟"
type Status = "todo" | "reading" | "done"
/**
* 三态:没打开过 / 读过但没读满或练习没做完 / 读满且练习全对。
* 没有练习的课只看阅读;「已读」的门槛沿用契约的 TUTORIAL_READ_SECONDS。
*/
function statusOf(id: number): Status {
const p = props.progress[id]
if (!p?.viewCount) return "todo"
const read = p.totalSeconds >= TUTORIAL_READ_SECONDS
const practiced = !p.exerciseTotal || p.exerciseSolved >= p.exerciseTotal
return read && practiced ? "done" : "reading"
}
function hint(id: number) {
const p = props.progress[id]
if (!p?.exerciseTotal) return ""
return `练一练 ${p.exerciseSolved}/${p.exerciseTotal}`
}
</script>
<template>
<n-list hoverable clickable>
<n-list-item
<ol class="lessons">
<li
v-for="(item, index) in titles"
:key="item.id"
class="lesson"
:class="{ active: step === index + 1 }"
@click="emit('select', index + 1)"
>
<!-- 标题独占一行:目录栏只有屏幕的五分之一宽,把「已读」摆在同一行会把
中文标题挤成两截 -->
<n-flex vertical :size="2">
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
<!-- 每篇教程都有一条进度(没读过的是一行零),所以这里判的是读没读过,
不是有没有这条记录。
满 TUTORIAL_READ_SECONDS 才打 ✓:打开过但没读满的仍然显示时长,
只是不带勾、也不是成功色 —— 记是记下了,还没到「已读」 -->
<n-text
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
type="success"
style="font-size: 12px"
>
✓ 已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-else-if="progress[item.id]?.viewCount"
depth="3"
style="font-size: 12px"
>
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? 'success'
: undefined
"
:depth="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? undefined
: 3
"
style="font-size: 12px"
>
练一练 {{ progress[item.id].exerciseSolved }} /
{{ progress[item.id].exerciseTotal }}
</n-text>
</n-flex>
</n-list-item>
</n-list>
<!-- 只在没登录时提一句。登录了却还没读的人不需要被提醒「你还没读」 -->
<n-text v-if="!traced" depth="3" style="display: block; padding: 8px 4px">
<span class="dot" :class="traced ? statusOf(item.id) : 'todo'">
<template v-if="traced && statusOf(item.id) === 'done'">✓</template>
<template v-else>{{ index + 1 }}</template>
</span>
<span class="text">
<span class="title">{{ item.title }}</span>
<span v-if="traced && hint(item.id)" class="hint">
{{ hint(item.id) }}
</span>
</span>
</li>
</ol>
<n-text v-if="!traced" depth="3" class="login-tip">
登录后可以记录学习进度
</n-text>
</template>
<style scoped>
.lessons {
list-style: none;
margin: 0;
padding: 0;
}
.lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s;
}
.lesson:hover {
background: rgba(128, 128, 128, 0.12);
}
.lesson.active {
background: rgba(24, 160, 88, 0.14);
}
.dot {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
}
.dot.reading {
border-color: #f0a020;
color: #f0a020;
}
.dot.done {
border-color: #18a058;
background: #18a058;
color: #fff;
}
.active .dot.todo {
border-color: #18a058;
color: #18a058;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
line-height: 1.4;
}
.active .title {
font-weight: 600;
}
.hint {
font-size: 12px;
opacity: 0.6;
}
.login-tip {
display: block;
padding: 8px 10px;
}
</style>