学生端:目录 + 居中限宽正文 + 可收起的示例代码栏;目录改成三态圆点, 顶部加总进度;上一课/下一课栏桌面端固定在底部。 教师端:汇总卡片、学生状态标签与筛选(未开始/7 天没学/只读不练等)、 最后学习补「N 天前」、按练习表加「没人一次对/多数人卡住」提示。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||||
import { NProgress, NText } from "naive-ui"
|
import { NProgress, NTag, NText } from "naive-ui"
|
||||||
import {
|
import {
|
||||||
getLearnStudents,
|
getLearnStudents,
|
||||||
getLearnTutorials,
|
getLearnTutorials,
|
||||||
@@ -46,19 +46,89 @@ const typeOptions = [
|
|||||||
{ label: "C 语言", value: "c" },
|
{ label: "C 语言", value: "c" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type StudentStatus = "idle" | "stalled" | "noPractice" | "going" | "done"
|
||||||
|
|
||||||
|
const STALL_DAYS = 7
|
||||||
|
const STATUS_META: Record<
|
||||||
|
StudentStatus,
|
||||||
|
{ label: string; type: "default" | "error" | "warning" | "info" | "success" }
|
||||||
|
> = {
|
||||||
|
idle: { label: "未开始", type: "error" },
|
||||||
|
stalled: { label: `${STALL_DAYS} 天没学`, type: "warning" },
|
||||||
|
noPractice: { label: "只读不练", type: "info" },
|
||||||
|
going: { label: "进行中", type: "default" },
|
||||||
|
done: { label: "已学完", type: "success" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个学生只落进一个状态,按「最需要老师看一眼」的顺序判:
|
||||||
|
// 没开始 > 学完了 > 停滞 > 只读不练 > 正常推进
|
||||||
|
function statusOf(row: LearnStudentProgress): StudentStatus {
|
||||||
|
if (row.readCount === 0 && row.totalSeconds === 0 && !row.exerciseTried) {
|
||||||
|
return "idle"
|
||||||
|
}
|
||||||
|
if (tutorialCount.value && row.readCount >= tutorialCount.value) return "done"
|
||||||
|
if (row.lastViewedAt) {
|
||||||
|
// 只比两个时刻相差多少毫秒,不涉及「哪一天」,所以不必走 time.ts 的日历口径
|
||||||
|
const days = (Date.now() - Date.parse(row.lastViewedAt)) / 86_400_000
|
||||||
|
if (days > STALL_DAYS) return "stalled"
|
||||||
|
}
|
||||||
|
if (exerciseCount.value && row.readCount > 0 && row.exerciseTried === 0) {
|
||||||
|
return "noPractice"
|
||||||
|
}
|
||||||
|
return "going"
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilter = ref<StudentStatus | "all">("all")
|
||||||
|
|
||||||
|
const statusCounts = computed(() => {
|
||||||
|
const counts: Record<StudentStatus, number> = {
|
||||||
|
idle: 0,
|
||||||
|
stalled: 0,
|
||||||
|
noPractice: 0,
|
||||||
|
going: 0,
|
||||||
|
done: 0,
|
||||||
|
}
|
||||||
|
for (const row of students.value) counts[statusOf(row)]++
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
const startedCount = computed(
|
const startedCount = computed(
|
||||||
() => students.value.filter((row) => row.readCount > 0).length,
|
() => students.value.length - statusCounts.value.idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const avgRead = computed(() =>
|
||||||
|
students.value.length
|
||||||
|
? (
|
||||||
|
students.value.reduce((n, row) => n + row.readCount, 0) /
|
||||||
|
students.value.length
|
||||||
|
).toFixed(1)
|
||||||
|
: "0",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 全班做题的总体正确口径:做对的题数 / 做过的题数
|
||||||
|
const solveRate = computed(() => {
|
||||||
|
const tried = students.value.reduce((n, row) => n + row.exerciseTried, 0)
|
||||||
|
const solved = students.value.reduce((n, row) => n + row.exerciseSolved, 0)
|
||||||
|
return tried ? Math.round((solved / tried) * 100) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function lastSeen(value: string | null) {
|
||||||
|
if (!value) return "-"
|
||||||
|
const days = Math.floor((Date.now() - Date.parse(value)) / 86_400_000)
|
||||||
|
const absolute = parseTime(value, "M月D日 HH:mm")
|
||||||
|
return days >= 1 ? `${absolute}(${days} 天前)` : absolute
|
||||||
|
}
|
||||||
|
|
||||||
// 姓名和学号都已经在手里,不再打接口。学号是纯数字,姓名是中文,
|
// 姓名和学号都已经在手里,不再打接口。学号是纯数字,姓名是中文,
|
||||||
// 一个框同时匹配两列就够了 —— 老师要么记得学号要么记得名字
|
// 一个框同时匹配两列就够了 —— 老师要么记得学号要么记得名字
|
||||||
const filteredStudents = computed(() => {
|
const filteredStudents = computed(() => {
|
||||||
const value = keyword.value.trim().toLowerCase()
|
const value = keyword.value.trim().toLowerCase()
|
||||||
if (!value) return students.value
|
|
||||||
return students.value.filter(
|
return students.value.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.username.toLowerCase().includes(value) ||
|
(statusFilter.value === "all" || statusOf(row) === statusFilter.value) &&
|
||||||
(row.realName ?? "").toLowerCase().includes(value),
|
(!value ||
|
||||||
|
row.username.toLowerCase().includes(value) ||
|
||||||
|
(row.realName ?? "").toLowerCase().includes(value)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,6 +141,19 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (row) => row.realName || "-",
|
render: (row) => row.realName || "-",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "status",
|
||||||
|
width: 110,
|
||||||
|
render: (row) => {
|
||||||
|
const meta = STATUS_META[statusOf(row)]
|
||||||
|
return h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: meta.type, bordered: false },
|
||||||
|
() => meta.label,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: `已读(共 ${tutorialCount.value} 课)`,
|
title: `已读(共 ${tutorialCount.value} 课)`,
|
||||||
key: "readCount",
|
key: "readCount",
|
||||||
@@ -128,10 +211,9 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
|
|||||||
{
|
{
|
||||||
title: "最后学习",
|
title: "最后学习",
|
||||||
key: "lastViewedAt",
|
key: "lastViewedAt",
|
||||||
width: 170,
|
width: 210,
|
||||||
sorter: "default",
|
sorter: "default",
|
||||||
render: (row) =>
|
render: (row) => lastSeen(row.lastViewedAt),
|
||||||
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -210,6 +292,31 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
|
|||||||
ellipsis: { tooltip: true },
|
ellipsis: { tooltip: true },
|
||||||
render: (row) => row.question || "(无题干)",
|
render: (row) => row.question || "(无题干)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 试的人不少、却没人一次做对,或者一半以上的人没做对 —— 多半是题有坑,
|
||||||
|
// 老师应该先去看展开里全班「最后一次错在」是不是同一个干扰项
|
||||||
|
title: "提示",
|
||||||
|
key: "flag",
|
||||||
|
width: 100,
|
||||||
|
render: (row) => {
|
||||||
|
if (row.triedUsers < 3) return null
|
||||||
|
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
|
||||||
|
return h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: "warning", bordered: false },
|
||||||
|
() => "没人一次对",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (row.solvedUsers / row.triedUsers < 0.5) {
|
||||||
|
return h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: "error", bordered: false },
|
||||||
|
() => "多数人卡住",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "做对 / 做过",
|
title: "做对 / 做过",
|
||||||
key: "solvedUsers",
|
key: "solvedUsers",
|
||||||
@@ -258,6 +365,7 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
expanded.value = []
|
expanded.value = []
|
||||||
|
statusFilter.value = "all"
|
||||||
const params = { type: type.value, className: className.value.trim() }
|
const params = { type: type.value, className: className.value.trim() }
|
||||||
try {
|
try {
|
||||||
// 三张表一起拉:切 tab 是纯前端的事,不该再等一次网络
|
// 三张表一起拉:切 tab 是纯前端的事,不该再等一次网络
|
||||||
@@ -312,6 +420,49 @@ onMounted(load)
|
|||||||
</n-text>
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
<n-grid
|
||||||
|
cols="2 s:3 m:5"
|
||||||
|
:x-gap="12"
|
||||||
|
:y-gap="12"
|
||||||
|
responsive="screen"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<n-gi>
|
||||||
|
<n-card size="small" :bordered="true">
|
||||||
|
<n-statistic label="学生" :value="studentCount" />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card size="small">
|
||||||
|
<n-statistic label="已开始" :value="startedCount">
|
||||||
|
<template #suffix>/ {{ students.length }}</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card size="small">
|
||||||
|
<n-statistic label="人均已读课数" :value="avgRead">
|
||||||
|
<template #suffix>/ {{ tutorialCount }}</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card size="small">
|
||||||
|
<n-statistic
|
||||||
|
label="练一练做对率"
|
||||||
|
:value="solveRate === null ? '-' : `${solveRate}%`"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card size="small">
|
||||||
|
<n-statistic label="停滞(7 天没学)" :value="statusCounts.stalled">
|
||||||
|
<template #suffix>人</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
<n-tabs v-model:value="tab" type="line" animated>
|
<n-tabs v-model:value="tab" type="line" animated>
|
||||||
<n-tab-pane name="students" tab="按学生">
|
<n-tab-pane name="students" tab="按学生">
|
||||||
<n-flex align="center" style="margin-bottom: 12px">
|
<n-flex align="center" style="margin-bottom: 12px">
|
||||||
@@ -325,6 +476,25 @@ onMounted(load)
|
|||||||
找到 {{ filteredStudents.length }} 人
|
找到 {{ filteredStudents.length }} 人
|
||||||
</n-text>
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
<n-flex :size="8" style="margin-bottom: 12px">
|
||||||
|
<n-tag
|
||||||
|
checkable
|
||||||
|
:checked="statusFilter === 'all'"
|
||||||
|
@update:checked="statusFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部 {{ students.length }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-for="(meta, key) in STATUS_META"
|
||||||
|
:key="key"
|
||||||
|
checkable
|
||||||
|
:type="meta.type"
|
||||||
|
:checked="statusFilter === key"
|
||||||
|
@update:checked="statusFilter = statusFilter === key ? 'all' : key"
|
||||||
|
>
|
||||||
|
{{ meta.label }} {{ statusCounts[key] }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:columns="studentColumns"
|
:columns="studentColumns"
|
||||||
|
|||||||
55
apps/web/src/oj/learn/components/LearnSummary.vue
Normal file
55
apps/web/src/oj/learn/components/LearnSummary.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||||
|
import type { TutorialProgress } from "utils/types"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
titles: { id: number; title: string }[]
|
||||||
|
progress: Record<number, TutorialProgress>
|
||||||
|
traced: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const rows = props.titles.map((t) => props.progress[t.id])
|
||||||
|
const read = rows.filter(
|
||||||
|
(p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS,
|
||||||
|
).length
|
||||||
|
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
|
||||||
|
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
|
||||||
|
return { read, solved, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
const percent = computed(() =>
|
||||||
|
props.titles.length
|
||||||
|
? Math.round((stats.value.read / props.titles.length) * 100)
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="traced && titles.length" class="summary">
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="percent"
|
||||||
|
:height="8"
|
||||||
|
:show-indicator="false"
|
||||||
|
status="success"
|
||||||
|
/>
|
||||||
|
<n-text depth="3" class="numbers">
|
||||||
|
已读 {{ stats.read }}/{{ titles.length }} 课
|
||||||
|
<template v-if="stats.total">
|
||||||
|
· 练一练 {{ stats.solved }}/{{ stats.total }}
|
||||||
|
</template>
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.summary {
|
||||||
|
padding: 4px 10px 12px;
|
||||||
|
}
|
||||||
|
.numbers {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
apps/web/src/oj/learn/components/LessonBody.vue
Normal file
24
apps/web/src/oj/learn/components/LessonBody.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { MdPreview } from "md-editor-v3"
|
||||||
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
import type { Segment } from "../composables/useExerciseParse"
|
||||||
|
|
||||||
|
defineProps<{ segments: Segment[]; lang?: string }>()
|
||||||
|
|
||||||
|
const isDark = useDark()
|
||||||
|
const ExerciseWidget = defineAsyncComponent(
|
||||||
|
() => import("./ExerciseWidget.vue"),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-for="(seg, i) in segments" :key="i">
|
||||||
|
<MdPreview
|
||||||
|
v-if="seg.type === 'md'"
|
||||||
|
preview-theme="vuepress"
|
||||||
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
|
:model-value="seg.content"
|
||||||
|
/>
|
||||||
|
<ExerciseWidget v-else :exercise="seg.exercise" :lang="lang" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||||
import type { TutorialProgress } from "utils/types"
|
import type { TutorialProgress } from "utils/types"
|
||||||
import { readableDuration } from "utils/functions"
|
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
titles: { id: number; title: string }[]
|
titles: { id: number; title: string }[]
|
||||||
step: number
|
step: number
|
||||||
/** 按教程 id 索引的自学留痕,未登录时是空的 */
|
/** 按教程 id 索引的自学留痕,未登录时是空的 */
|
||||||
@@ -14,70 +13,114 @@ defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ select: [lesson: number] }>()
|
const emit = defineEmits<{ select: [lesson: number] }>()
|
||||||
|
|
||||||
// 打开过但一秒都没攒够时 readableDuration 给的是 "-",「读了 -」不像人话。
|
type Status = "todo" | "reading" | "done"
|
||||||
// 心跳 15 秒一跳,点开就走确实会落在 0 上
|
|
||||||
function readSoFar(seconds: number) {
|
/**
|
||||||
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟"
|
* 三态:没打开过 / 读过但没读满或练习没做完 / 读满且练习全对。
|
||||||
|
* 没有练习的课只看阅读;「已读」的门槛沿用契约的 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-list hoverable clickable>
|
<ol class="lessons">
|
||||||
<n-list-item
|
<li
|
||||||
v-for="(item, index) in titles"
|
v-for="(item, index) in titles"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
class="lesson"
|
||||||
|
:class="{ active: step === index + 1 }"
|
||||||
@click="emit('select', index + 1)"
|
@click="emit('select', index + 1)"
|
||||||
>
|
>
|
||||||
<!-- 标题独占一行:目录栏只有屏幕的五分之一宽,把「已读」摆在同一行会把
|
<span class="dot" :class="traced ? statusOf(item.id) : 'todo'">
|
||||||
中文标题挤成两截 -->
|
<template v-if="traced && statusOf(item.id) === 'done'">✓</template>
|
||||||
<n-flex vertical :size="2">
|
<template v-else>{{ index + 1 }}</template>
|
||||||
<n-text
|
</span>
|
||||||
:type="step === index + 1 ? 'primary' : undefined"
|
<span class="text">
|
||||||
:strong="step === index + 1"
|
<span class="title">{{ item.title }}</span>
|
||||||
>
|
<span v-if="traced && hint(item.id)" class="hint">
|
||||||
{{ index + 1 }}. {{ item.title }}
|
{{ hint(item.id) }}
|
||||||
</n-text>
|
</span>
|
||||||
<!-- 每篇教程都有一条进度(没读过的是一行零),所以这里判的是读没读过,
|
</span>
|
||||||
不是有没有这条记录。
|
</li>
|
||||||
满 TUTORIAL_READ_SECONDS 才打 ✓:打开过但没读满的仍然显示时长,
|
</ol>
|
||||||
只是不带勾、也不是成功色 —— 记是记下了,还没到「已读」 -->
|
<n-text v-if="!traced" depth="3" class="login-tip">
|
||||||
<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">
|
|
||||||
登录后可以记录学习进度
|
登录后可以记录学习进度
|
||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
38
apps/web/src/oj/learn/components/PagerBar.vue
Normal file
38
apps/web/src/oj/learn/components/PagerBar.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
|
||||||
|
defineProps<{ step: number; total: number }>()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const emit = defineEmits<{ go: [lesson: number] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="pager" :style="{ background: theme.bodyColor }">
|
||||||
|
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
|
||||||
|
← 上一课
|
||||||
|
</n-button>
|
||||||
|
<n-text depth="3">{{ step }} / {{ total }}</n-text>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:secondary="step >= total"
|
||||||
|
:disabled="step >= total"
|
||||||
|
@click="emit('go', step + 1)"
|
||||||
|
>
|
||||||
|
下一课 →
|
||||||
|
</n-button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Exercise } from "utils/types"
|
import type { Exercise } from "utils/types"
|
||||||
|
|
||||||
type Segment =
|
export type Segment =
|
||||||
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
|
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
|
||||||
|
|
||||||
export function parseExercises(
|
export function parseExercises(
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="learn-container">
|
<div class="learn-container">
|
||||||
<!-- 桌面端布局 -->
|
<template v-if="tutorial.id">
|
||||||
<n-grid
|
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
|
||||||
:cols="5"
|
<div
|
||||||
:x-gap="16"
|
v-if="isDesktop"
|
||||||
v-if="tutorial.id && isDesktop"
|
class="learn-layout"
|
||||||
class="learn-grid"
|
:class="{ 'with-code': codeOpen }"
|
||||||
>
|
>
|
||||||
<n-gi :span="1" class="learn-col">
|
<aside class="rail">
|
||||||
<n-card title="教程目录" :bordered="false" size="small">
|
<LearnSummary
|
||||||
|
:titles="titles"
|
||||||
|
:progress="progress"
|
||||||
|
:traced="traced"
|
||||||
|
/>
|
||||||
<LessonList
|
<LessonList
|
||||||
:titles="titles"
|
:titles="titles"
|
||||||
:step="step"
|
:step="step"
|
||||||
@@ -16,103 +20,60 @@
|
|||||||
:traced="traced"
|
:traced="traced"
|
||||||
@select="goToLesson"
|
@select="goToLesson"
|
||||||
/>
|
/>
|
||||||
</n-card>
|
</aside>
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
|
<main class="reader">
|
||||||
<n-card
|
<article class="reader-body">
|
||||||
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
|
<header class="lesson-head">
|
||||||
:bordered="false"
|
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
|
||||||
size="small"
|
<n-flex align="center" justify="space-between" :wrap="false">
|
||||||
>
|
<span />
|
||||||
<template v-for="(seg, i) in segments" :key="i">
|
<n-button
|
||||||
<MdPreview
|
v-if="tutorial.code"
|
||||||
v-if="seg.type === 'md'"
|
size="small"
|
||||||
preview-theme="vuepress"
|
secondary
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
@click="codeOpen = !codeOpen"
|
||||||
:model-value="seg.content"
|
>
|
||||||
/>
|
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
|
||||||
<ExerciseWidget
|
</n-button>
|
||||||
v-else
|
</n-flex>
|
||||||
:exercise="seg.exercise"
|
</header>
|
||||||
:lang="tutorial.type"
|
<LessonBody :segments="segments" :lang="tutorial.type" />
|
||||||
/>
|
</article>
|
||||||
</template>
|
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
|
||||||
</n-card>
|
</main>
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
|
<aside v-if="tutorial.code && codeOpen" class="code-panel">
|
||||||
<n-card
|
|
||||||
title="示例代码"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
class="code-card"
|
|
||||||
content-style="height: calc(100% - 44px); padding: 0;"
|
|
||||||
>
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
:language="editorLanguage"
|
:language="editorLanguage"
|
||||||
v-model="tutorial.code"
|
v-model="tutorial.code"
|
||||||
height="100%"
|
height="100%"
|
||||||
/>
|
/>
|
||||||
</n-card>
|
</aside>
|
||||||
</n-gi>
|
</div>
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<!-- 手机端布局 -->
|
<!-- 手机端 -->
|
||||||
<template v-if="tutorial.id && !isDesktop">
|
<template v-else>
|
||||||
<n-tabs type="line" animated v-model:value="activeTab">
|
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
|
||||||
<n-tab-pane name="catalog" tab="目录">
|
<n-tabs type="line" animated v-model:value="activeTab">
|
||||||
<LessonList
|
<n-tab-pane name="catalog" tab="目录">
|
||||||
:titles="titles"
|
<LessonList
|
||||||
:step="step"
|
:titles="titles"
|
||||||
:progress="progress"
|
:step="step"
|
||||||
:traced="traced"
|
:progress="progress"
|
||||||
@select="goToLesson"
|
:traced="traced"
|
||||||
/>
|
@select="goToLesson"
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<n-tab-pane name="content" :tab="`第 ${step} 课`">
|
|
||||||
<template v-for="(seg, i) in segments" :key="i">
|
|
||||||
<MdPreview
|
|
||||||
v-if="seg.type === 'md'"
|
|
||||||
preview-theme="vuepress"
|
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
|
||||||
:model-value="seg.content"
|
|
||||||
/>
|
/>
|
||||||
<ExerciseWidget
|
</n-tab-pane>
|
||||||
v-else
|
<n-tab-pane name="content" :tab="`第 ${step} 课`">
|
||||||
:exercise="seg.exercise"
|
<LessonBody :segments="segments" :lang="tutorial.type" />
|
||||||
:lang="tutorial.type"
|
</n-tab-pane>
|
||||||
/>
|
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
||||||
</template>
|
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
|
||||||
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
|
</template>
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<n-divider style="margin: 12px 0" />
|
|
||||||
|
|
||||||
<n-flex align="center" justify="space-between">
|
|
||||||
<n-button
|
|
||||||
secondary
|
|
||||||
type="primary"
|
|
||||||
:disabled="isFirstLesson"
|
|
||||||
@click="goToPrevLesson"
|
|
||||||
>
|
|
||||||
← 上一课
|
|
||||||
</n-button>
|
|
||||||
<n-text>{{ step }} / {{ titles.length }}</n-text>
|
|
||||||
<n-button
|
|
||||||
secondary
|
|
||||||
type="primary"
|
|
||||||
:disabled="isLastLesson"
|
|
||||||
@click="goToNextLesson"
|
|
||||||
>
|
|
||||||
下一课 →
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-empty
|
<n-empty
|
||||||
@@ -124,8 +85,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MdPreview } from "md-editor-v3"
|
|
||||||
import "md-editor-v3/lib/preview.css"
|
|
||||||
import type {
|
import type {
|
||||||
Tutorial,
|
Tutorial,
|
||||||
Exercise,
|
Exercise,
|
||||||
@@ -144,15 +103,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
|||||||
import { useLearnProgress } from "shared/composables/learnProgress"
|
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import LessonList from "./components/LessonList.vue"
|
import LessonList from "./components/LessonList.vue"
|
||||||
|
import LearnSummary from "./components/LearnSummary.vue"
|
||||||
const ExerciseWidget = defineAsyncComponent(
|
import LessonBody from "./components/LessonBody.vue"
|
||||||
() => import("./components/ExerciseWidget.vue"),
|
import PagerBar from "./components/PagerBar.vue"
|
||||||
)
|
|
||||||
const CodeEditor = defineAsyncComponent(
|
const CodeEditor = defineAsyncComponent(
|
||||||
() => import("shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDark = useDark()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
@@ -186,6 +143,8 @@ const titles = ref<{ id: number; title: string }[]>([])
|
|||||||
const progress = ref<Record<number, TutorialProgress>>({})
|
const progress = ref<Record<number, TutorialProgress>>({})
|
||||||
const exercises = ref<Exercise[]>([])
|
const exercises = ref<Exercise[]>([])
|
||||||
const activeTab = ref("content")
|
const activeTab = ref("content")
|
||||||
|
// 示例代码栏默认展开,收起后正文独占版面;偏好记在本机
|
||||||
|
const codeOpen = useStorage("oj2:learn-code-open", true)
|
||||||
const isEmpty = ref(false)
|
const isEmpty = ref(false)
|
||||||
|
|
||||||
const segments = computed(() =>
|
const segments = computed(() =>
|
||||||
@@ -198,22 +157,12 @@ useLearnTrace(
|
|||||||
traced,
|
traced,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isFirstLesson = computed(() => step.value === 1)
|
|
||||||
const isLastLesson = computed(() => step.value === titles.value.length)
|
|
||||||
|
|
||||||
function goToLesson(lessonNumber: number) {
|
function goToLesson(lessonNumber: number) {
|
||||||
activeTab.value = "content"
|
activeTab.value = "content"
|
||||||
router.push(
|
router.push(
|
||||||
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
function goToPrevLesson() {
|
|
||||||
if (step.value > 1) goToLesson(step.value - 1)
|
|
||||||
}
|
|
||||||
function goToNextLesson() {
|
|
||||||
if (step.value < titles.value.length) goToLesson(step.value + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
|
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
|
||||||
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
|
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
|
||||||
@@ -263,27 +212,58 @@ watch(traced, loadProgress)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
|
/* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
.learn-container {
|
.learn-container {
|
||||||
height: calc(100vh - 138px);
|
height: calc(100vh - 138px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.learn-grid {
|
.learn-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.learn-layout.with-code {
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
.learn-col {
|
.rail,
|
||||||
|
.reader {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.reader {
|
||||||
.learn-col--code {
|
display: flex;
|
||||||
overflow-y: hidden;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.reader-body {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.reader :deep(.pager) {
|
||||||
|
max-width: 820px;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-card {
|
.lesson-head h1,
|
||||||
|
.mobile-title {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.mobile-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user