feat(自学): 自学重做——首页课程地图、读一课按项目分目录、代码块就地运行、学完给题做
Deploy / deploy (push) Canceled after 0s

- 顶栏「自学」进新的自学首页 /learn:接着学(按服务端记录,不再串到上一个学生)+ 按项目分组的课程地图
- 读一课:左边目录按项目折叠,正文加宽,右边本课大纲带练一练标记;去掉了不能运行的示例代码栏
- 正文里的代码块能改能运行,报错用题目页那套中文解释;读输入的代码出输入框
- 课末:练一练做到几道 → 学完了 → 老师挑的几道题(必会标出来)→ 下一课
- 练一练统一成绿色提交、结果一行字;手机版目录从底部弹出
- 后台:教程列表按项目分组拖动排序,编辑页左写右看、练一练插到光标处、挑相关题;
  自学情况改成方块格 + 还没打开过的名单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 09:40:34 +08:00
1 parent 11194543a3
commit 845e41c060
43 files changed
+4675 -1294

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `chartTheme`, `breakpoints`, `maxkb`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
+30 -18
View File
@@ -9,8 +9,7 @@ import type {
AdminAiReport,
AdminAiReportList,
StuckProblem,
LearnStudentProgressList,
LearnTutorialProgressList,
LearnBoard,
LearnExerciseProgressList,
LearnExerciseAttempt,
AdminContestList,
@@ -41,9 +40,11 @@ import type {
AdminProblemSetProblem,
AddProblemsToSetResult,
TutorialListItem,
TutorialEdit,
TagCategory,
} from "utils/types"
import type {
TutorialProblemCandidate,
AstCheckRequest,
AstCheckResponse,
GenerateTestInputsRequest,
@@ -416,25 +417,44 @@ export function getTutorial(id: number) {
return api.get<Tutorial>(`admin/tutorials/${id}`)
}
function toTutorialBody(data: Partial<Tutorial>) {
/** 「示例代码」界面上去掉了,`code` 不带:后端照原样留着发布脚本写进去的那份 */
function toTutorialBody(data: TutorialEdit) {
return {
title: data.title,
content: data.content,
code: data.code ?? null,
isPublic: data.isPublic ?? false,
order: data.order ?? 0,
project: data.project,
isPublic: data.isPublic,
order: data.order,
type: data.type,
}
}
export function createTutorial(data: Partial<Tutorial>) {
export function createTutorial(data: TutorialEdit) {
return api.post<Tutorial>("admin/tutorials", toTutorialBody(data))
}
export function updateTutorial(data: Partial<Tutorial>) {
export function updateTutorial(data: TutorialEdit) {
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
}
export function setTutorialProblems(id: number, problemIds: number[]) {
return api.put<Tutorial>(`admin/tutorials/${id}/problems`, { problemIds })
}
export function getTutorialProblemCandidates(keyword: string) {
return api.get<TutorialProblemCandidate[]>("admin/tutorial-problem-candidates", {
params: keyword ? { keyword } : {},
})
}
export function reorderTutorials(ids: number[]) {
return api.put<null>("admin/tutorial-order", { ids })
}
export function renameTutorialProject(type: "python" | "c", from: string, to: string) {
return api.put<null>("admin/tutorial-projects", { type, from, to })
}
export function deleteTutorial(id: number) {
return api.delete(`admin/tutorials/${id}`)
}
@@ -583,16 +603,8 @@ export function getStuckProblems() {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
}
export function getLearnStudents(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnStudentProgressList>("admin/learn-analytics/students", {
params,
})
}
export function getLearnTutorials(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnTutorialProgressList>("admin/learn-analytics/tutorials", {
params,
})
export function getLearnBoard(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnBoard>("admin/learn-analytics/board", { params })
}
export function getLearnExercises(params: { type: "python" | "c"; className?: string }) {
File diff suppressed because it is too large. Load diff
@@ -1,42 +0,0 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteTutorial } from "admin/api"
interface Props {
tutorialID: number
}
const props = defineProps<Props>()
const emit = defineEmits(["deleted"])
const router = useRouter()
const message = useMessage()
function goEdit() {
router.push({
name: "admin tutorial edit",
params: { tutorialID: props.tutorialID },
})
}
async function handleDelete() {
try {
await deleteTutorial(props.tutorialID)
message.success("删除成功")
emit("deleted")
} catch (err) {
message.error(errorMessage(err))
}
}
</script>
<template>
<n-flex>
<n-button size="small" type="success" secondary @click="goEdit"> 编辑 </n-button>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button size="small" type="error" secondary>删除</n-button>
</template>
确定删除这个教程吗?
</n-popconfirm>
</n-flex>
</template>
<style scoped></style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { errorMessage } from "utils/api"
import type {
Exercise,
@@ -13,7 +14,17 @@ import type {
} from "utils/types"
import { getAdminExercises, createExercise, updateExercise, deleteExercise } from "admin/api"
const props = defineProps<{ tutorialId: number }>()
/**
* 编辑页右栏的「练一练」:一道一行,写着它在正文的哪一节;新建的直接插到正文光标处,
* 不用再「复制占位符」去粘。表单(七种题型)还是原来那个弹框。
*/
const props = defineProps<{ tutorialId: number; content: string }>()
const emit = defineEmits<{
/** 把这道题的占位符插到正文光标处 */
insert: [exerciseId: number]
/** 列表变了(预览要跟着换) */
change: [exercises: Exercise[]]
}>()
const message = useMessage()
const dialog = useDialog()
@@ -54,6 +65,19 @@ const debugLines = computed(() => (debugCode.value === "" ? [] : debugCode.value
async function load() {
exercises.value = await getAdminExercises(props.tutorialId)
emit("change", exercises.value)
}
/** 这道题在正文的哪一节:占位符前面最近的二级标题;没放进正文就是 null */
function placeOf(id: number) {
const at = props.content.indexOf(`[[exercise:${id}]]`)
if (at < 0) return null
const before = props.content.slice(0, at).split("\n")
for (let i = before.length - 1; i >= 0; i--) {
const heading = before[i]!.match(/^##\s+(.+)$/)
if (heading) return heading[1]!.trim()
}
return "开头"
}
onMounted(load)
@@ -297,13 +321,14 @@ async function save() {
})
message.success("练习题已更新")
} else {
await createExercise({
const created = await createExercise({
tutorialId: props.tutorialId,
type: formType.value,
data,
order: formOrder.value,
})
message.success("练习题已创建")
message.success("已新建,插到了正文光标处")
emit("insert", created.id)
}
showForm.value = false
await load()
@@ -325,76 +350,50 @@ function confirmDelete(id: number) {
})
}
function copyPlaceholder(id: number) {
navigator.clipboard.writeText(`[[exercise:${id}]]`)
message.success(`已复制 [[exercise:${id}]]`)
}
const TYPE_NAMES: Record<ExerciseType, string> = {
mcq: "选择题",
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
}
const TYPE_TAGS: Record<
ExerciseType,
"success" | "info" | "warning" | "error" | "primary" | "default"
> = {
mcq: "success",
sort: "info",
fill: "warning",
match: "primary",
predict: "error",
debug: "info",
group: "warning",
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
function typeName(type: ExerciseType) {
return TYPE_NAMES[type] ?? type
}
function typeTagType(type: ExerciseType) {
return TYPE_TAGS[type] ?? "default"
}
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<n-text>共 {{ exercises.length }} 道练习题</n-text>
<n-button type="primary" size="small" @click="openCreate">+ 添加练习题</n-button>
</n-flex>
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
<n-list v-else bordered>
<n-list-item v-for="ex in exercises" :key="ex.id">
<n-flex justify="space-between" align="center">
<div>
<n-tag size="small" :type="typeTagType(ex.type)" :bordered="false">
{{ typeName(ex.type) }}
</n-tag>
<n-text style="margin-left: 10px">
{{ (ex.data as { question?: string }).question }}
</n-text>
</div>
<n-space :size="8">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" @click="copyPlaceholder(ex.id)"> 复制占位符 </n-button>
</template>
将 [[exercise:{{ ex.id }}]] 粘贴到 Markdown 内容中
</n-tooltip>
<n-button size="small" @click="openEdit(ex)">编辑</n-button>
<n-button size="small" type="error" @click="confirmDelete(ex.id)"> 删除 </n-button>
</n-space>
</n-flex>
</n-list-item>
</n-list>
<div class="head">
<span class="title">练一练</span>
<span class="muted">{{ exercises.length }} 道</span>
<span class="grow" />
<n-button size="small" @click="openCreate">
<template #icon><Icon icon="ph:plus" /></template>
新建
</n-button>
</div>
<div v-if="exercises.length === 0" class="muted empty">还没有练一练</div>
<div v-for="(ex, index) in exercises" :key="ex.id" class="item">
<div class="line">
<span class="muted num">{{ index + 1 }}</span>
<span class="type">{{ typeName(ex.type) }}</span>
<span class="question">{{ (ex.data as { question?: string }).question }}</span>
</div>
<div class="line sub">
<span v-if="placeOf(ex.id)" class="muted">在「{{ placeOf(ex.id) }}」</span>
<template v-else>
<span class="warn">还没放进正文</span>
<a href="#" @click.prevent="emit('insert', ex.id)">插到光标处</a>
</template>
<span class="grow" />
<a href="#" @click.prevent="openEdit(ex)">编辑</a>
<a href="#" class="danger" @click.prevent="confirmDelete(ex.id)">删除</a>
</div>
</div>
<n-modal
v-model:show="showForm"
@@ -628,3 +627,75 @@ function typeTagType(type: ExerciseType) {
</n-modal>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.title {
font-weight: 700;
}
.muted {
opacity: 0.6;
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.grow {
flex: 1;
}
.empty {
padding: 8px 0;
}
.item {
padding: 9px 0;
border-top: 1px solid rgba(128, 128, 128, 0.15);
display: flex;
flex-direction: column;
gap: 4px;
}
.line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sub {
font-size: 12px;
padding-left: 16px;
}
.type {
height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
background: rgba(128, 128, 128, 0.12);
flex-shrink: 0;
}
.question {
flex: 1;
min-width: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.warn {
color: #c77c02;
}
.sub a {
color: #18a058;
text-decoration: none;
}
.sub a.danger {
color: #d03050;
opacity: 0.8;
}
</style>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import {
TUTORIAL_PROBLEM_MAX,
type AdminTutorialProblem,
type TutorialProblemCandidate,
} from "@oj2/contract"
import { errorMessage } from "utils/api"
import { getTutorialProblemCandidates, setTutorialProblems } from "admin/api"
/**
* 一课结尾「去做几道题」挑哪几道(设计稿「后台 · 编辑一课」右栏)。
* 不搜的时候列出必会题,搜的时候必会题排在前面。改了就存,不等页面上的「保存」。
*/
const props = defineProps<{ tutorialId: number; problems: AdminTutorialProblem[] }>()
const emit = defineEmits<{ update: [problems: AdminTutorialProblem[]] }>()
const message = useMessage()
const keyword = ref("")
const candidates = ref<TutorialProblemCandidate[]>([])
const dragIndex = ref<number | null>(null)
const suggestions = computed(() => {
const picked = new Set(props.problems.map((item) => item.id))
return candidates.value.filter((item) => !picked.has(item.id)).slice(0, 8)
})
async function search() {
try {
candidates.value = await getTutorialProblemCandidates(keyword.value.trim())
} catch {
candidates.value = []
}
}
const searchLater = useDebounceFn(search, 300)
watch(keyword, searchLater)
onMounted(search)
async function save(next: AdminTutorialProblem[]) {
const before = props.problems
emit("update", next)
try {
await setTutorialProblems(
props.tutorialId,
next.map((item) => item.id),
)
} catch (err) {
emit("update", before)
message.error(errorMessage(err))
}
}
function add(item: TutorialProblemCandidate) {
if (props.problems.length >= TUTORIAL_PROBLEM_MAX) {
message.warning(`一课最多挂 ${TUTORIAL_PROBLEM_MAX} 道`)
return
}
const { tags: _, ...problem } = item
save([...props.problems, problem])
}
function remove(id: number) {
save(props.problems.filter((item) => item.id !== id))
}
function drop(target: number) {
const from = dragIndex.value
dragIndex.value = null
if (from === null || from === target) return
const next = [...props.problems]
const [moved] = next.splice(from, 1)
next.splice(target, 0, moved!)
save(next)
}
</script>
<template>
<div>
<div class="head">
<span class="title">相关题</span>
<span class="muted">{{ problems.length }} 道</span>
</div>
<div
v-for="(problem, index) in problems"
:key="problem.id"
class="row"
draggable="true"
@dragstart="dragIndex = index"
@dragover.prevent
@drop="drop(index)"
>
<span class="handle" aria-hidden="true"
><Icon icon="ph:dots-six-vertical-bold" :width="14"
/></span>
<span class="pid">{{ problem.displayId }}</span>
<span class="name">{{ problem.title }}</span>
<span v-if="problem.mustDo" class="must">必会</span>
<button type="button" class="x" aria-label="移除" @click="remove(problem.id)">
<Icon icon="ph:x" :width="13" />
</button>
</div>
<n-input v-model:value="keyword" size="small" clearable placeholder="题号或标题" class="search">
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<div v-if="!keyword.trim()" class="muted hint">必会题</div>
<div v-for="item in suggestions" :key="item.id" class="row">
<span class="pid">{{ item.displayId }}</span>
<span class="name">{{ item.title }}</span>
<span v-if="item.mustDo" class="must">必会</span>
<span v-else class="tags">{{ item.tags.join(" · ") }}</span>
<n-button size="tiny" @click="add(item)">
<template #icon><Icon icon="ph:plus" /></template>
加
</n-button>
</div>
<div v-if="keyword.trim() && !suggestions.length" class="muted hint">没找到</div>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.title {
font-weight: 700;
}
.muted {
opacity: 0.6;
font-size: 13px;
}
.row {
height: 38px;
display: flex;
align-items: center;
gap: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.handle {
display: flex;
opacity: 0.45;
cursor: grab;
}
.pid {
width: 34px;
flex-shrink: 0;
font-size: 12px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.name {
flex: 1;
min-width: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.must {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
flex-shrink: 0;
background: rgba(240, 160, 32, 0.16);
color: #8a5200;
}
html.dark .must {
color: #f2c97d;
}
.tags {
font-size: 12px;
opacity: 0.55;
white-space: nowrap;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
.x {
border: 0;
background: transparent;
color: inherit;
opacity: 0.55;
display: flex;
padding: 4px;
cursor: pointer;
}
.x:hover {
opacity: 1;
}
.search {
margin: 10px 0 4px;
}
.hint {
font-size: 12px;
margin: 8px 0 2px;
}
</style>
+309 -70
View File
@@ -1,123 +1,362 @@
<script lang="ts" setup>
import PageHeader from "admin/components/PageHeader.vue"
import { Icon } from "@iconify/vue"
import type { AdminTutorialProblem } from "@oj2/contract"
import { errorMessage } from "utils/api"
import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import type { TutorialEdit } from "utils/types"
import { createTutorial, getTutorial, updateTutorial } from "../api"
import type { Exercise, TutorialEdit, TutorialListItem } from "utils/types"
import { parseLesson } from "oj/learn/composables/useLessonParse"
import { joinTitle, lessonPath, splitTitle } from "oj/learn/lesson"
import LessonBody from "oj/learn/components/LessonBody.vue"
import "oj/learn/learn.css"
import {
createTutorial,
deleteTutorial,
getTutorial,
getTutorialList,
updateTutorial,
} from "../api"
import ExerciseManager from "./components/ExerciseManager.vue"
import RelatedProblems from "./components/RelatedProblems.vue"
interface Props {
tutorialID?: string
}
/**
* 编辑一课(设计稿「后台 · 编辑一课」):知识点 / 情境 / 项目三个字段,正文左边写、
* 右边用学生端同一套组件预览(练一练、能运行的代码块都看得到),右栏是练一练和相关题。
* 「示例代码」字段去掉了:正文里的代码块直接能运行。
*/
const props = defineProps<{ tutorialID?: string }>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const props = defineProps<Props>()
const isCreate = computed(() => route.name === "admin tutorial create")
const tutorial = reactive<TutorialEdit>({
id: 0,
title: "",
content: "",
code: "",
project: typeof route.query.project === "string" ? route.query.project : "",
isPublic: false,
order: 0,
type: "python", // 默认选择 Python
type: route.query.type === "c" ? "c" : "python",
})
const kp = ref("")
const scene = ref("")
const problems = ref<AdminTutorialProblem[]>([])
const exercises = ref<Exercise[]>([])
const allTutorials = ref<{ [key: string]: TutorialListItem[] }>({ python: [], c: [] })
const saving = ref(false)
const editor = useTemplateRef<InstanceType<typeof MarkdownEditor>>("editor")
const typeOptions = [
{ label: "Python", value: "python" },
{ label: "C 语言", value: "c" },
]
const siblings = computed(() => allTutorials.value[tutorial.type] ?? [])
const projectOptions = computed(() => {
const names = [...new Set(siblings.value.map((item) => item.project).filter(Boolean))]
if (tutorial.project && !names.includes(tutorial.project)) names.push(tutorial.project)
return names.map((name) => ({ label: name, value: name }))
})
/** 学生那边是第几课:只数公开的 */
const step = computed(() => {
const visible = siblings.value.filter((item) => item.isPublic)
const index = visible.findIndex((item) => item.id === tutorial.id)
return index >= 0 ? index + 1 : null
})
const heading = computed(() => {
if (isCreate.value) return "新建一课"
const label = tutorial.type === "c" ? "C 语言" : "Python"
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
})
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
async function init() {
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
if (!props.tutorialID) {
// 新建:顺序排在这门课最后
tutorial.order = Math.max(0, ...siblings.value.map((item) => item.order)) + 1
return
}
const id = parseInt(route.params.tutorialID as string)
const data = await getTutorial(id)
const data = await getTutorial(parseInt(props.tutorialID))
tutorial.id = data.id
tutorial.title = data.title
tutorial.content = data.content
tutorial.code = data.code || ""
tutorial.project = data.project
tutorial.isPublic = data.isPublic
tutorial.order = data.order
tutorial.type = data.type || "python"
tutorial.type = data.type
problems.value = data.problems
const parts = splitTitle(data.title)
kp.value = parts.kp
scene.value = parts.scene
}
async function submit() {
if (!tutorial.title || !tutorial.content) {
message.error("标题和正文必填")
tutorial.title = joinTitle(kp.value, scene.value)
if (!kp.value.trim() || !tutorial.content.trim()) {
message.error("知识点和正文必填")
return
}
saving.value = true
try {
if (route.name === "admin tutorial create") {
await createTutorial({
title: tutorial.title,
content: tutorial.content,
code: tutorial.code,
isPublic: tutorial.isPublic,
order: tutorial.order,
type: tutorial.type,
})
message.success("成功新建教程 💐")
if (isCreate.value) {
const created = await createTutorial(tutorial)
message.success("已新建,接着可以加练一练和相关题")
router.replace({ name: "admin tutorial edit", params: { tutorialID: created.id } })
} else {
await updateTutorial(tutorial)
message.success("修改已保存")
message.success("已保存")
allTutorials.value = await getTutorialList().catch(() => allTutorials.value)
}
} catch (err) {
message.error(errorMessage(err))
} finally {
saving.value = false
}
}
async function remove() {
try {
await deleteTutorial(tutorial.id)
message.success("已删除")
router.push({ name: "admin tutorial list" })
} catch (err) {
message.error(errorMessage(err))
}
}
function insertExercise(id: number) {
editor.value?.insert(`[[exercise:${id}]]`)
}
function openStudentView() {
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, "_blank")
}
watch(() => props.tutorialID, init)
onMounted(init)
</script>
<template>
<PageHeader
:title="route.name === 'admin tutorial create' ? '新建教程' : '编辑教程'"
:back="{ name: 'admin tutorial list' }"
/>
<n-form inline>
<n-form-item label="标题">
<n-input class="contestTitle" v-model:value="tutorial.title" />
</n-form-item>
<n-form-item label="语言">
<n-select v-model:value="tutorial.type" :options="typeOptions" class="select" />
</n-form-item>
<n-form-item label="顺序">
<n-input-number style="width: 100px" v-model:value="tutorial.order" :min="0" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="tutorial.isPublic" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submit">保存</n-button>
</n-form-item>
</n-form>
<div class="page">
<div class="top">
<router-link :to="{ name: 'admin tutorial list' }" class="back">
<Icon icon="ph:caret-left-bold" :width="13" />教程
</router-link>
<span class="slash">/</span>
<span class="heading">{{ heading }}</span>
<span class="grow" />
<label class="public">
<n-switch v-model:value="tutorial.isPublic" size="small" />
公开
</label>
<n-button v-if="!isCreate" :disabled="!step" @click="openStudentView">
<template #icon><Icon icon="ph:eye" /></template>
看学生那边
</n-button>
<n-popconfirm v-if="!isCreate" @positive-click="remove">
<template #trigger>
<n-button quaternary type="error">删除</n-button>
</template>
这一课连同它的练一练、学生的进度一起删掉,确定吗?
</n-popconfirm>
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
</div>
<n-tabs type="line" animated>
<n-tab-pane name="content" tab="教程内容">
<MarkdownEditor v-model:value="tutorial.content" />
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码">
<CodeEditor
v-model:value="tutorial.code"
:language="tutorial.type === 'python' ? 'Python' : 'C'"
height="400px"
/>
</n-tab-pane>
<n-tab-pane name="exercises" tab="练习题" :disabled="!tutorial.id">
<ExerciseManager v-if="tutorial.id" :tutorial-id="tutorial.id" />
<n-empty v-else description="请先保存教程后再添加练习题" />
</n-tab-pane>
</n-tabs>
<div class="fields">
<label class="field">
<span>知识点</span>
<n-input v-model:value="kp" placeholder="条件语句基础" style="width: 200px" />
</label>
<label class="field">
<span>情境</span>
<n-input v-model:value="scene" placeholder="蜜橘园环境预警" style="width: 200px" />
</label>
<label class="field">
<span>项目</span>
<n-select
v-model:value="tutorial.project"
:options="projectOptions"
filterable
tag
clearable
placeholder="选一个,或者打字新建"
style="width: 250px"
/>
</label>
<label v-if="isCreate" class="field">
<span>语言</span>
<n-select v-model:value="tutorial.type" :options="typeOptions" style="width: 110px" />
</label>
</div>
<div class="work">
<section class="card editor-card">
<div class="card-head">
<span class="strong">正文</span>
<span class="muted">Markdown</span>
</div>
<div class="split">
<div class="source">
<MarkdownEditor
ref="editor"
v-model:value="tutorial.content"
:preview="false"
height="100%"
/>
</div>
<div class="preview">
<LessonBody
:segments="preview.segments"
:lang="tutorial.type"
:tutorial-id="tutorial.id"
:exercise-count="preview.exerciseCount"
:solved-ids="new Set()"
/>
</div>
</div>
</section>
<aside class="side">
<section class="card pad">
<ExerciseManager
v-if="tutorial.id"
:tutorial-id="tutorial.id"
:content="tutorial.content"
@insert="insertExercise"
@change="exercises = $event"
/>
<div v-else class="muted">保存之后就能加练一练</div>
</section>
<section class="card pad">
<RelatedProblems
v-if="tutorial.id"
:tutorial-id="tutorial.id"
:problems="problems"
@update="problems = $event"
/>
<div v-else class="muted">保存之后就能挑相关题</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.select {
width: 100px;
}
.contestTitle {
width: 400px;
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 14px;
height: calc(100vh - 96px);
min-height: 600px;
}
.top {
display: flex;
align-items: center;
gap: 10px;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
color: #18a058;
text-decoration: none;
}
.slash {
opacity: 0.4;
}
.heading {
font-size: 18px;
font-weight: 700;
}
.grow {
flex: 1;
}
.public {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.fields {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.field {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.work {
flex: 1;
min-height: 0;
display: flex;
gap: 16px;
}
.card {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 8px;
}
.pad {
padding: 12px 14px;
}
.editor-card {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-head {
height: 38px;
flex-shrink: 0;
padding: 0 12px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid rgba(128, 128, 128, 0.22);
font-size: 13px;
}
.strong {
font-weight: 600;
}
.muted {
opacity: 0.6;
font-size: 13px;
}
.split {
flex: 1;
min-height: 0;
display: flex;
}
.source {
flex: 0 0 50%;
min-width: 0;
border-right: 1px solid rgba(128, 128, 128, 0.22);
}
.source :deep(.md-editor) {
border: 0;
border-radius: 0;
}
.preview {
flex: 0 0 50%;
min-width: 0;
box-sizing: border-box;
padding: 12px 20px;
overflow-y: auto;
}
.side {
width: 330px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
</style>
+409 -76
View File
@@ -1,96 +1,429 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { Icon } from "@iconify/vue"
import { errorMessage } from "utils/api"
import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions"
import type { TutorialListItem } from "utils/types"
import { getTutorialList, setTutorialVisibility } from "../api"
import Actions from "./components/Actions.vue"
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
import ProjectTile from "oj/learn/components/ProjectTile.vue"
import "oj/learn/learn.css"
import {
getTutorialList,
renameTutorialProject,
reorderTutorials,
setTutorialVisibility,
} from "../api"
const tutorials = ref<{ [key: string]: TutorialListItem[] }>({
python: [],
c: [],
})
/**
* 教程列表(设计稿「后台 · 教程列表」):按项目分组,拖动排序;每课看得到几道练一练、
* 挑没挑相关题、多少学生打开过。整组都没公开、又没填项目的(老教程)收起来放最后。
*/
const router = useRouter()
const route = useRoute()
const message = useMessage()
const activeTab = ref("python")
const columns: DataTableColumn<TutorialListItem>[] = [
{
title: "顺序",
key: "order",
width: 80,
},
{ title: "标题", key: "title", minWidth: 200 },
{
title: "作者",
key: "createdBy",
render: (row) => row.createdBy?.username,
width: 80,
},
{
title: "创建时间",
key: "createdAt",
width: 180,
render: (row) => parseTime(row.createdAt!, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "更新时间",
key: "updatedAt",
width: 180,
render: (row) => parseTime(row.updatedAt!, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "isPublic",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.isPublic,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
},
{
title: "操作",
key: "actions",
width: 140,
render: (row) => h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
},
]
const type = ref<TutorialType>(route.query.type === "c" ? "c" : "python")
const all = ref<{ [key: string]: TutorialListItem[] }>({ python: [], c: [] })
const collapsed = ref(new Set<string>())
const renaming = ref<string | null>(null)
const renameTo = ref("")
const dragId = ref<number | null>(null)
const showNewProject = ref(false)
const newProject = ref("")
async function toggleVisible(tutorial: TutorialListItem) {
tutorial.isPublic = !tutorial.isPublic
const list = computed(() => all.value[type.value] ?? [])
const groups = computed(() =>
groupByProject(list.value)
.map((group) => {
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!)
return {
...group,
items,
key: `${group.project}:${group.lessons[0]!.id}`,
hidden: items.every((item) => !item.isPublic),
}
})
// 整组没公开、又没项目的老教程放到最后,别压在正在用的课上面
.sort((a, b) => Number(a.hidden && !a.project) - Number(b.hidden && !b.project)),
)
/** 学生那边看到的序号:只数公开的 */
const publicStep = computed(() => {
const steps = new Map<number, number>()
list.value.filter((item) => item.isPublic).forEach((item, index) => steps.set(item.id, index + 1))
return steps
})
async function load() {
all.value = await getTutorialList()
// 整组没公开又没项目的(老教程)默认收起
collapsed.value = new Set(
groups.value.filter((group) => group.hidden && !group.project).map((group) => group.key),
)
}
function pickType(value: TutorialType) {
type.value = value
router.replace({ query: { type: value } })
load()
}
function toggle(key: string) {
const next = new Set(collapsed.value)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsed.value = next
}
async function toggleVisible(item: TutorialListItem) {
item.isPublic = !item.isPublic
try {
await setTutorialVisibility(tutorial.id, tutorial.isPublic)
message.success("更新成功")
await setTutorialVisibility(item.id, item.isPublic)
} catch (err) {
item.isPublic = !item.isPublic
message.error(errorMessage(err))
tutorial.isPublic = !tutorial.isPublic
}
}
async function listTutorials() {
tutorials.value = await getTutorialList()
function startRename(project: string) {
renaming.value = project
renameTo.value = project
}
onMounted(listTutorials)
async function saveRename() {
const from = renaming.value
const to = renameTo.value.trim()
renaming.value = null
if (from === null || !to || to === from) return
try {
await renameTutorialProject(type.value, from, to)
await load()
} catch (err) {
message.error(errorMessage(err))
}
}
/** 拖动只在同一个项目里排:换项目到编辑页里改,免得一拖把课拖进别的项目 */
async function drop(target: TutorialListItem) {
const from = list.value.find((item) => item.id === dragId.value)
dragId.value = null
if (!from || from.id === target.id) return
if (from.project !== target.project) {
message.warning("只能在同一个项目里拖,换项目请到编辑页里改")
return
}
const ids = list.value.map((item) => item.id).filter((id) => id !== from.id)
const at = ids.indexOf(target.id)
const fromIndex = list.value.findIndex((item) => item.id === from.id)
const targetIndex = list.value.findIndex((item) => item.id === target.id)
ids.splice(fromIndex < targetIndex ? at + 1 : at, 0, from.id)
// 先在本地换好位置,拖完立刻看到
const byId = new Map(list.value.map((item) => [item.id, item]))
all.value = {
...all.value,
[type.value]: ids.map((id, index) => ({ ...byId.get(id)!, order: index + 1 })),
}
try {
await reorderTutorials(ids)
} catch (err) {
message.error(errorMessage(err))
await load()
}
}
function createIn(project: string) {
router.push({ name: "admin tutorial create", query: { type: type.value, project } })
}
function confirmNewProject() {
const name = newProject.value.trim()
if (!name) return
showNewProject.value = false
newProject.value = ""
createIn(name)
}
onMounted(load)
</script>
<template>
<PageHeader title="教程列表">
<template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin tutorial create' })">
新建教程
<div class="page">
<div class="top">
<span class="title">教程</span>
<div class="seg">
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">
Python
</button>
<button type="button" :class="{ on: type === 'c' }" @click="pickType('c')">C 语言</button>
</div>
<span class="grow" />
<n-button @click="showNewProject = true">
<template #icon><Icon icon="ph:plus" /></template>
新建项目
</n-button>
</template>
</PageHeader>
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="python" tab="Python">
<n-data-table striped :columns="columns" :data="tutorials.python" />
</n-tab-pane>
<n-tab-pane name="c" tab="C 语言">
<n-data-table striped :columns="columns" :data="tutorials.c" />
</n-tab-pane>
</n-tabs>
<n-button type="primary" @click="createIn('')">
<template #icon><Icon icon="ph:plus" /></template>
新建一课
</n-button>
</div>
<section class="table">
<div class="row headrow">
<span class="c-drag" />
<span class="c-no">#</span>
<span class="c-title">知识点 · 情境</span>
<span class="c-num">练一练</span>
<span class="c-num">相关题</span>
<span class="c-num wide">打开过的人</span>
<span class="c-switch">公开</span>
<span class="c-edit" />
</div>
<template v-for="group in groups" :key="group.key">
<div class="row grouprow">
<button
type="button"
class="fold"
:aria-label="collapsed.has(group.key) ? '展开' : '收起'"
@click="toggle(group.key)"
>
<Icon
:icon="collapsed.has(group.key) ? 'ph:caret-right-bold' : 'ph:caret-down-bold'"
:width="13"
/>
</button>
<ProjectTile :icon="group.icon" :tone="group.tone" :size="26" />
<template v-if="renaming === group.project && group.project">
<n-input
v-model:value="renameTo"
size="small"
style="width: 280px"
autofocus
@keyup.enter="saveRename"
@keyup.esc="renaming = null"
/>
<n-button size="small" type="primary" @click="saveRename">保存</n-button>
<n-button size="small" quaternary @click="renaming = null">取消</n-button>
</template>
<template v-else>
<span class="group-name">{{
group.project || (group.hidden ? "没公开" : "没有项目")
}}</span>
<span class="muted">{{ group.items.length }} 课</span>
<a v-if="group.project" href="#" @click.prevent="startRename(group.project)">改名</a>
<a href="#" @click.prevent="createIn(group.project)">加一课</a>
</template>
</div>
<template v-if="!collapsed.has(group.key)">
<div
v-for="item in group.items"
:key="item.id"
class="row lesson"
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
draggable="true"
@dragstart="dragId = item.id"
@dragend="dragId = null"
@dragover.prevent
@drop="drop(item)"
>
<span class="c-drag handle" aria-hidden="true"
><Icon icon="ph:dots-six-vertical-bold" :width="16"
/></span>
<span class="c-no num muted">{{ publicStep.get(item.id) ?? "—" }}</span>
<span class="c-title">
<span class="kp">{{ splitTitle(item.title).kp }}</span>
<span class="scene">{{ splitTitle(item.title).scene }}</span>
</span>
<span class="c-num num">{{ item.exerciseCount || "-" }}</span>
<span class="c-num num">
<template v-if="item.problemCount">{{ item.problemCount }}</template>
<span v-else class="warn">还没挑</span>
</span>
<span class="c-num wide num">{{ item.readers }}</span>
<span class="c-switch">
<n-switch
:value="item.isPublic"
size="small"
:rubber-band="false"
@update:value="toggleVisible(item)"
/>
</span>
<router-link
:to="{ name: 'admin tutorial edit', params: { tutorialID: item.id } }"
class="c-edit"
>
编辑
</router-link>
</div>
</template>
</template>
</section>
<n-modal v-model:show="showNewProject" preset="card" title="新建项目" style="width: 420px">
<n-input
v-model:value="newProject"
placeholder="项目五 · 智慧物流·仓储管理"
maxlength="64"
@keyup.enter="confirmNewProject"
/>
<template #footer>
<n-flex justify="end">
<n-button @click="showNewProject = false">取消</n-button>
<n-button type="primary" :disabled="!newProject.trim()" @click="confirmNewProject">
接着写第一课
</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.title {
font-size: 20px;
font-weight: 700;
}
.seg {
display: flex;
margin-left: 8px;
}
.seg button {
height: 32px;
padding: 0 12px;
border: 1px solid rgba(128, 128, 128, 0.3);
background: transparent;
color: inherit;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.seg button:first-child {
border-radius: 4px 0 0 4px;
}
.seg button:last-child {
border-radius: 0 4px 4px 0;
margin-left: -1px;
}
.seg button.on {
border-color: #18a058;
color: var(--learn-green-text);
font-weight: 600;
position: relative;
}
.grow {
flex: 1;
}
.muted {
opacity: 0.6;
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.table {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 8px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 14px;
height: 44px;
padding: 0 16px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.headrow {
height: 38px;
border-top: 0;
font-size: 12px;
opacity: 0.6;
}
.grouprow {
gap: 10px;
background: rgba(128, 128, 128, 0.06);
border-top-color: rgba(128, 128, 128, 0.22);
}
.grouprow a {
font-size: 13px;
color: #18a058;
text-decoration: none;
}
.fold {
border: 0;
background: transparent;
color: inherit;
display: flex;
padding: 2px;
opacity: 0.55;
cursor: pointer;
}
.group-name {
font-weight: 700;
}
.lesson.off {
opacity: 0.6;
}
.lesson.dragging {
opacity: 0.35;
}
.handle {
display: flex;
opacity: 0.4;
cursor: grab;
}
.c-drag {
width: 16px;
}
.c-no {
width: 22px;
font-size: 13px;
}
.c-title {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
}
.kp {
font-weight: 600;
white-space: nowrap;
}
.scene {
font-size: 13px;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-num {
width: 70px;
text-align: right;
font-size: 13px;
}
.c-num.wide {
width: 80px;
}
.warn {
color: var(--learn-orange-text);
}
.c-switch {
width: 60px;
display: flex;
justify-content: center;
}
.c-edit {
width: 40px;
text-align: right;
font-size: 13px;
color: #18a058;
text-decoration: none;
}
</style>
+1 -1
View File
@@ -9,6 +9,7 @@ import {
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type Tutorial,
type ClassActivity,
type LastVisit,
type ClassBoard,
@@ -67,7 +68,6 @@ import type {
Submission,
SubmissionListPayload,
SubmitCodePayload,
Tutorial,
TutorialProgress,
} from "utils/types"
+16 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from "oj/learn/lesson"
import { Icon } from "@iconify/vue"
import {
getAnnouncement,
@@ -29,8 +30,6 @@ import type {
WeeklyRank,
} from "utils/types"
type TutorialType = "python" | "c"
interface Track {
type: TutorialType
label: string
@@ -38,8 +37,8 @@ interface Track {
step: number
title: string
total: number
/** 打开过的课数 */
read: number
/** 学完的课数(练一练全对) */
done: number
lastViewedAt: string | null
}
@@ -107,27 +106,26 @@ const weeklyText = computed(() => {
})
/**
* 接着学哪一课看的是**服务端留痕**里最近打开的那一课,不是导航栏用的
* localStorage:机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的:
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
const [titles, progress] = await Promise.all([getTutorials(type), getLearnProgress(type)])
const [titles, rows] = await Promise.all([getTutorials(type), getLearnProgress(type)])
if (!titles.length) return null
const seen = progress.filter((p) => p.lastViewedAt)
const latest = seen.reduce<(typeof seen)[number] | null>(
(acc, p) => (!acc || p.lastViewedAt! > acc.lastViewedAt! ? p : acc),
const progress = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
const step = continueStep(titles, progress) ?? 1
const lastViewedAt = rows.reduce<string | null>(
(acc, row) => (row.lastViewedAt && (!acc || row.lastViewedAt > acc) ? row.lastViewedAt : acc),
null,
)
const index = latest ? titles.findIndex((t) => t.id === latest.tutorialId) : -1
const step = index >= 0 ? index + 1 : 1
return {
type,
label,
step,
title: titles[step - 1]!.title,
title: splitTitle(titles[step - 1]!.title).kp,
total: titles.length,
read: seen.length,
lastViewedAt: latest?.lastViewedAt ?? null,
done: doneCount(titles, progress),
lastViewedAt,
}
}
@@ -271,7 +269,7 @@ watch(
)
function learnLink(track: Track) {
return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}`
return lessonPath(track.type, track.step)
}
const { jump, jumping } = useProblemJump()
@@ -441,13 +439,13 @@ async function openAnnouncement(item: AnnouncementListItem) {
<div class="track-title">{{ track.title }}</div>
<n-progress
type="line"
:percentage="Math.round((track.read / track.total) * 100)"
:percentage="Math.round((track.done / track.total) * 100)"
status="success"
:show-indicator="false"
:height="6"
/>
<n-text depth="3" class="track-meta">
已打开 {{ track.read }} 课
学完 {{ track.done }} 课
<template v-if="track.lastViewedAt">
· 上次 {{ relativeTime(track.lastViewedAt) }}
</template>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { StatisticInfo, TrialRunResponse } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { trialRun } from "utils/judge"
import { explainCCompileError } from "oj/problem/utils/cError"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
/**
* 正文里的一段代码:就地改、就地运行(设计稿「代码块就地运行」那块画板)。
*
* 运行走题目页同一台判题机(POST /trial-runs,带 tutorialId),不算提交。
* 报错用题目页那套中文解释(pythonError / cError / runtimeError),原文收在下面。
* 代码里读输入(input() / scanf)才出输入框。
*/
const props = defineProps<{
code: string
lang: "python" | "c"
tutorialId: number
}>()
const userStore = useUserStore()
const authModal = useAuthModalStore()
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const value = ref(props.code)
const input = ref("")
const running = ref(false)
type Outcome =
| { kind: "output"; text: string }
| { kind: "error"; title: string; message: string; raw: string }
| { kind: "busy"; message: string }
const outcome = ref<Outcome | null>(null)
// 换课时组件实例可能被复用(v-for 的 key 是段落序号),跟着原文重置
watch(
() => props.code,
(code) => {
value.value = code
input.value = ""
outcome.value = null
},
)
const edited = computed(() => value.value !== props.code)
const needsInput = computed(() =>
props.lang === "python"
? /\binput\s*\(/.test(value.value)
: /\b(scanf|getchar|gets|fgets|cin)\b/.test(value.value),
)
const language = computed(() => (props.lang === "c" ? "C" : "Python"))
const lineCount = computed(() => value.value.split("\n").length)
const TOO_MUCH_OUTPUT =
"程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。"
/** 判题机的临时目录名对学生没有意义,只留文件名 */
function cleanRaw(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "").trim()
}
function compileOutcome(raw: string): Outcome {
const ex =
props.lang === "python"
? explainPythonCompileError(raw, value.value)
: explainCCompileError(raw, value.value, "C")
return {
kind: "error",
title: ex?.line ? `第 ${ex.line} 行出错` : "代码有错,没能运行",
message: ex?.message ?? "",
raw: cleanRaw(raw),
}
}
function runtimeOutcome(
result: SubmissionStatus,
output: string,
info: NonNullable<StatisticInfo["runtime_error"]> | undefined,
): Outcome {
if (result === SubmissionStatus.runtime_error) {
return {
kind: "error",
title: info?.line ? `运行到第 ${info.line} 行出错了` : "运行到一半出错了",
message: info ? explainRuntimeError(info, value.value) : "",
raw: cleanRaw(output),
}
}
if (
result === SubmissionStatus.cpu_time_limit_exceeded ||
result === SubmissionStatus.real_time_limit_exceeded
) {
return {
kind: "error",
title: "运行超时了",
message: needsInput.value
? "程序一直没停下来:看看是不是在等输入(上面的输入框填够了吗),或者循环停不下来。"
: "程序一直没停下来:多半是循环没有结束的条件。",
raw: cleanRaw(output),
}
}
if (result === SubmissionStatus.memory_limit_exceeded) {
return { kind: "error", title: "内存用太多了", message: "", raw: cleanRaw(output) }
}
return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
}
function toOutcome(response: TrialRunResponse): Outcome {
if (response.status === "compile-error") return compileOutcome(response.message)
if (response.status === "too-much-output") {
return { kind: "error", title: "输出太多了", message: TOO_MUCH_OUTPUT, raw: "" }
}
const [item] = response.cases
if (!item) return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
const result = item.result as SubmissionStatus
if (result === SubmissionStatus.accepted || result === SubmissionStatus.wrong_answer) {
return { kind: "output", text: item.output.trimEnd() }
}
return runtimeOutcome(result, item.output, item.runtimeError)
}
async function run() {
if (!userStore.isAuthed) {
authModal.openLoginModal()
return
}
if (running.value || !value.value.trim()) return
running.value = true
try {
const response = await trialRun({
tutorialId: props.tutorialId,
language: language.value,
code: value.value,
cases: [{ input: input.value }],
})
outcome.value = toOutcome(response)
} catch (error) {
// 后端的文案是给学生看的(「上一次还没跑完」「试运行的人太多了」)
outcome.value = {
kind: "busy",
message: errorMessage(error, "运行的服务暂时连不上,过一会儿再试。"),
}
} finally {
running.value = false
}
}
function restore() {
value.value = props.code
outcome.value = null
}
async function copyCode() {
await copy(value.value)
message.success("已复制")
}
</script>
<template>
<div class="block">
<div class="bar">
<span class="lang">{{ language }}</span>
<span class="grow" />
<template v-if="edited">
<span class="edited">改过</span>
<button type="button" class="tool" @click="restore">
<Icon icon="ph:arrow-counter-clockwise" :width="14" />还原
</button>
</template>
<button type="button" class="run" :class="{ busy: running }" :disabled="running" @click="run">
<template v-if="running">运行中…</template>
<template v-else><Icon icon="ph:play-fill" :width="12" />运行</template>
</button>
<button type="button" class="tool" aria-label="复制" @click="copyCode">
<Icon icon="ph:copy" :width="14" />
</button>
</div>
<div class="editor" :style="{ minHeight: `${lineCount * 22 + 16}px` }">
<CodeEditor v-model:value="value" :language="language" :font-size="14" height="auto" />
</div>
<div v-if="needsInput" class="pane input">
<div class="label">输入</div>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="程序要读的内容写在这里,一行一个"
class="mono"
/>
</div>
<div v-if="outcome?.kind === 'output'" class="pane">
<div class="label">输出</div>
<pre class="out" :class="{ empty: !outcome.text }">{{
outcome.text || "(什么都没有输出)"
}}</pre>
</div>
<div v-else-if="outcome?.kind === 'error'" class="pane error">
<div class="label strong">{{ outcome.title }}</div>
<div v-if="outcome.message" class="explain">{{ outcome.message }}</div>
<pre v-if="outcome.raw" class="raw">{{ outcome.raw }}</pre>
</div>
<div v-else-if="outcome?.kind === 'busy'" class="pane">
<div class="busy-text">{{ outcome.message }}</div>
</div>
</div>
</template>
<style scoped>
.block {
margin: 0 0 16px;
border: 1px solid var(--learn-line);
border-radius: 6px;
overflow: hidden;
background: var(--learn-soft);
}
.bar {
height: 36px;
box-sizing: border-box;
padding: 0 6px 0 14px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--learn-line);
font-size: 12px;
}
.lang {
opacity: 0.55;
}
.grow {
flex: 1;
}
.edited {
color: var(--learn-orange-text);
}
.tool {
height: 26px;
padding: 0 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
opacity: 0.7;
font: inherit;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.tool:hover {
opacity: 1;
background: var(--learn-soft);
}
.run {
height: 26px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 4px;
border: 1px solid var(--learn-green);
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.run:hover {
background: var(--learn-green-bg);
}
.run.busy {
border-color: var(--learn-line);
color: inherit;
opacity: 0.6;
font-weight: 400;
cursor: default;
}
.editor :deep(.cm-editor) {
background: transparent;
}
.editor :deep(.cm-gutters) {
background: transparent;
border-right: 0;
}
.editor :deep(.cm-scroller) {
font-family: var(--learn-code-font);
line-height: 22px;
padding: 8px 0;
}
.pane {
border-top: 1px solid var(--learn-line);
padding: 10px 14px 12px;
background: var(--learn-pane);
}
.label {
font-size: 12px;
opacity: 0.6;
margin-bottom: 4px;
}
.label.strong {
opacity: 1;
font-weight: 600;
color: var(--learn-red-text);
}
.out,
.raw {
margin: 0;
font-family: var(--learn-code-font);
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-all;
}
.out.empty {
opacity: 0.5;
}
.explain {
font-size: 14px;
line-height: 1.7;
margin-bottom: 6px;
}
.raw {
font-size: 12px;
line-height: 18px;
opacity: 0.55;
}
.busy-text {
font-size: 14px;
color: var(--learn-orange-text);
}
.mono :deep(textarea) {
font-family: var(--learn-code-font);
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -83,11 +84,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -109,20 +106,19 @@ function reset() {
</div>
</n-space>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
style="margin-top: 12px"
>
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
</n-alert>
</ExerciseResult>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
<n-button type="primary" :disabled="locked" @click="submit">提交</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -72,11 +73,7 @@ function inputWidth(idx: number): string {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -124,7 +121,7 @@ function inputWidth(idx: number): string {
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template></pre>
<n-alert
<ExerciseResult
v-if="wrongBlanks.size > 0 || allCorrect"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部正确!' : '有填写错误,请检查红色标注的空位'"
@@ -132,8 +129,8 @@ function inputWidth(idx: number): string {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
@@ -80,11 +81,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -156,16 +153,15 @@ function reset() {
</div>
</div>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
@@ -129,11 +130,7 @@ function dotStyle(color: string): Record<string, string> {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -173,16 +170,15 @@ function dotStyle(color: string): Record<string, string> {
</n-space>
</div>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
@@ -8,6 +9,8 @@ const emit = defineEmits<{
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
// 选项都不长就排两列,省一半高度;有一条长的就一条一行,免得折成两三行
const twoColumns = computed(() => data.value.options.every((opt) => opt.length <= 16))
const selected = ref<Set<number>>(new Set())
const correct = ref(false)
@@ -75,46 +78,29 @@ function optionType(idx: number): "default" | "primary" | "success" {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-space align="center" :size="8">
<n-tag type="success" :bordered="false">
练一练 · {{ isSingle ? "单选题" : "多选题" }}
</n-tag>
</n-space>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
<div>
<p class="question">
{{ data.question }}
</p>
<n-space vertical :size="8">
<n-button
<div class="opts" :class="{ wide: twoColumns }">
<button
v-for="(opt, idx) in data.options"
:key="idx"
:type="optionType(idx)"
:secondary="optionType(idx) !== 'default'"
:tertiary="optionType(idx) === 'default'"
:strong="selected.has(idx)"
:style="{
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left',
}"
type="button"
class="opt"
:class="optionType(idx)"
@click="select(idx)"
>
<template #icon>
<span style="font-weight: 700">{{ String.fromCharCode(65 + idx) }}</span>
</template>
{{ opt }}
</n-button>
</n-space>
<span class="key">{{ String.fromCharCode(65 + idx) }}</span>
<span>{{ opt }}</span>
</button>
</div>
<n-alert
<ExerciseResult
v-if="correct || wrong || partial"
:type="correct ? 'success' : partial ? 'warning' : 'error'"
:title="correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
@@ -123,5 +109,73 @@ function optionType(idx: number): "default" | "primary" | "success" {
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
<style scoped>
.question {
font-weight: 600;
font-size: 15px;
line-height: 1.6;
margin: 0 0 12px;
}
.opts {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.opts.wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 768px) {
.opts.wide {
grid-template-columns: minmax(0, 1fr);
}
}
.opt {
min-height: 40px;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
font-size: 14px;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.opt:hover {
border-color: #18a058;
}
.key {
width: 22px;
height: 22px;
flex-shrink: 0;
box-sizing: border-box;
border-radius: 11px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
display: grid;
place-items: center;
font-size: 12px;
opacity: 0.85;
}
.opt.primary {
border-color: #18a058;
background: rgba(24, 160, 88, 0.08);
}
.opt.primary .key,
.opt.success .key {
border-color: #18a058;
background: #18a058;
color: #fff;
opacity: 1;
}
.opt.success {
border-color: #18a058;
background: rgba(24, 160, 88, 0.12);
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -55,11 +56,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -88,16 +85,15 @@ function reset() {
style="font-family: Monaco"
/>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="error" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
/** 练一练判完的那一行:对了绿色打勾,错了红字。原来是 n-alert 一整块色条,比题目还抢眼 */
defineProps<{ type: "success" | "warning" | "error"; title: string }>()
</script>
<template>
<div class="result" :class="type">
<div class="line">
<Icon v-if="type === 'success'" icon="ph:check-bold" :width="16" />
<span>{{ title }}</span>
</div>
<div v-if="$slots.default" class="more"><slot /></div>
</div>
</template>
<style scoped>
.result {
margin-top: 12px;
font-size: 14px;
line-height: 1.6;
}
.line {
display: flex;
align-items: center;
gap: 6px;
}
.success .line {
color: #18a058;
font-weight: 600;
}
.warning .line {
color: var(--learn-orange-text);
}
.error .line {
color: var(--learn-red-text);
}
.more {
margin-top: 4px;
opacity: 0.85;
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight"
@@ -70,11 +71,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -116,16 +113,15 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
</div>
</n-space>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Exercise } from "utils/types"
import { Icon } from "@iconify/vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
import { reportExerciseAttempt } from "oj/api"
import { useUserStore } from "shared/store/user"
@@ -11,10 +12,33 @@ const ExercisePredict = defineAsyncComponent(() => import("./ExercisePredict.vue
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const props = defineProps<{
exercise: Exercise
lang?: string
/** 这一课的第几道、一共几道 */
index: number
total: number
/** 做对过(以前做对的,或者刚才做对的) */
solved: boolean
}>()
const emit = defineEmits<{ solved: [exerciseId: number] }>()
const userStore = useUserStore()
const KIND: Record<string, string> = {
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
}
const kind = computed(() => {
if (props.exercise.type !== "mcq") return KIND[props.exercise.type] ?? "练习"
return (props.exercise.data as ExerciseMcqData).answer.length === 1 ? "单选题" : "多选题"
})
/**
* 七种题型各自判完对错后都往上抛 attempt,留痕只在这里做一次 ——
* 每个题型组件里各写一遍上报,早晚会漏掉一两个。
@@ -36,6 +60,8 @@ watch(
)
function onAttempt(payload: { correct: boolean; answer?: string }) {
// 做对了就告诉页面(目录、大纲、课末的进度跟着变),没登录也算 —— 只是不留痕
if (payload.correct) emit("solved", props.exercise.id)
if (!userStore.isAuthed || solved) return
const answer = payload.answer ?? ""
if (answer === lastAnswer) return
@@ -46,31 +72,95 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
</script>
<template>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" @attempt="onAttempt" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" @attempt="onAttempt" />
<section class="exercise" :id="`exercise-${exercise.id}`">
<header class="head">
<span class="tag">练一练</span>
<span class="kind">{{ kind }}</span>
<span class="grow" />
<span v-if="solved" class="done"><Icon icon="ph:check-bold" :width="13" />做对过</span>
<span class="num">第 {{ index }} 道 · 共 {{ total }} 道</span>
</header>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch
v-else-if="exercise.type === 'match'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup
v-else-if="exercise.type === 'group'"
:exercise="exercise"
@attempt="onAttempt"
/>
</section>
</template>
<style scoped>
.exercise {
margin: 0 0 18px;
padding: 16px 18px;
border: 1px solid var(--learn-line);
border-radius: 8px;
scroll-margin-top: 16px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.tag {
height: 22px;
padding: 0 8px;
border-radius: 3px;
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 600;
background: var(--learn-green-bg);
color: var(--learn-green-text);
}
.kind {
opacity: 0.6;
}
.grow {
flex: 1;
}
.done {
display: inline-flex;
align-items: center;
gap: 3px;
color: #18a058;
font-size: 12px;
font-weight: 600;
}
.num {
font-size: 12px;
opacity: 0.55;
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,49 +0,0 @@
<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>
+77 -12
View File
@@ -1,22 +1,87 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Segment } from "../composables/useExerciseParse"
import type { LessonSegment } from "../composables/useLessonParse"
import CodeRunBlock from "./CodeRunBlock.vue"
defineProps<{ segments: Segment[]; lang?: string }>()
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
defineProps<{
segments: LessonSegment[]
lang: "python" | "c"
tutorialId: number
exerciseCount: number
solvedIds: Set<number>
}>()
const emit = defineEmits<{ solved: [exerciseId: number] }>()
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>
<div class="body">
<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"
class="md"
/>
<h2 v-else-if="seg.type === 'heading'" :id="seg.id" class="h2">{{ seg.text }}</h2>
<div v-else-if="seg.type === 'goals'" :id="seg.id" class="goals">
<div class="goals-title">学完这课你能</div>
<div v-for="item in seg.items" :key="item">· {{ item.replace(/`/g, "") }}</div>
</div>
<CodeRunBlock
v-else-if="seg.type === 'code'"
:code="seg.code"
:lang="seg.lang"
:tutorial-id="tutorialId"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="lang"
:index="seg.index + 1"
:total="exerciseCount"
:solved="solvedIds.has(seg.exercise.id)"
@solved="emit('solved', $event)"
/>
</template>
</div>
</template>
<style scoped>
.body {
font-size: 15px;
line-height: 1.85;
}
.md :deep(.md-editor-preview) {
font-size: 15px;
padding: 0;
}
.md :deep(.md-editor-preview-wrapper) {
padding: 0;
}
.h2 {
font-size: 20px;
line-height: 1.4;
margin: 28px 0 12px;
scroll-margin-top: 16px;
}
.goals {
margin: 4px 0 8px;
padding: 12px 16px;
border-radius: 6px;
background: var(--learn-soft);
font-size: 14px;
line-height: 1.8;
scroll-margin-top: 16px;
}
.goals-title {
font-weight: 700;
margin-bottom: 2px;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { LessonState } from "../lesson"
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
defineProps<{ step: number; state: LessonState; current?: boolean }>()
</script>
<template>
<span class="dot" :class="state === 'done' ? 'done' : current ? 'current' : state">
<Icon v-if="state === 'done'" icon="ph:check-bold" :width="13" />
<template v-else>{{ step }}</template>
</span>
</template>
<style scoped>
.dot {
width: 24px;
height: 24px;
flex-shrink: 0;
box-sizing: border-box;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 12px;
font-variant-numeric: tabular-nums;
border: 1.5px solid var(--learn-todo);
opacity: 0.9;
}
.done {
border: 0;
background: var(--learn-green);
color: #fff;
opacity: 1;
}
.started {
border: 2px solid var(--learn-orange);
color: var(--learn-orange-text);
font-weight: 600;
}
.current {
border: 2px solid var(--learn-green);
color: var(--learn-green-text);
font-weight: 700;
opacity: 1;
}
</style>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProblem } from "@oj2/contract"
import { splitTitle, type LessonState, type ProjectGroup } from "../lesson"
import SegBar from "./SegBar.vue"
/**
* 一课的结尾(设计稿「一课的结尾」):练一练做到几道 → 全对就是「第 N 课学完了」→
* 老师挑的几道题 → 下一课。练一练没做完时下一课按钮是次要样式,不拦着。
*/
const props = defineProps<{
step: number
total: number
courseLabel: string
exerciseCount: number
solvedCount: number
/** 第一道还没做对的练一练,点了跳回去 */
firstUnsolved: { id: number; index: number } | null
problems: TutorialProblem[]
next: { title: string; to: string } | null
groups: ProjectGroup[]
stateOf: (id: number) => LessonState
doneCount: number
/** 窄屏:进度条短一点、题号挪到题目名下面 */
compact?: boolean
}>()
const emit = defineEmits<{ jump: [exerciseId: number] }>()
const finished = computed(() => props.exerciseCount > 0 && props.solvedCount >= props.exerciseCount)
const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : null))
</script>
<template>
<div class="end">
<section v-if="finished" class="banner">
<span class="check"><Icon icon="ph:check-bold" :width="compact ? 22 : 24" /></span>
<div class="banner-text">
<span class="banner-title">第 {{ step }} 课学完了</span>
<div class="banner-progress">
<SegBar
:groups="groups"
:state-of="stateOf"
:width="compact ? 8 : 13"
:height="compact ? 5 : 6"
:gap="compact ? 2 : 3"
:group-gap="compact ? 5 : 8"
/>
<span class="muted num"
>{{ compact ? "" : `${courseLabel} 学完 ` }}{{ doneCount }} / {{ total
}}{{ compact ? "" : " 课" }}</span
>
</div>
</div>
</section>
<section v-else-if="exerciseCount" class="card practice">
<span class="strong">练一练</span>
<span class="num">{{ solvedCount }} / {{ exerciseCount }}</span>
<span class="boxes">
<span v-for="n in exerciseCount" :key="n" class="box" :class="{ on: n <= solvedCount }" />
</span>
<span class="grow" />
<a v-if="firstUnsolved" href="#" class="link" @click.prevent="emit('jump', firstUnsolved.id)">
<Icon icon="ph:arrow-up-bold" :width="14" />第 {{ firstUnsolved.index }} 道还没做对
</a>
</section>
<section v-if="problems.length" class="card problems">
<div class="problems-head strong">去做几道题</div>
<router-link
v-for="problem in problems"
:key="problem.id"
:to="`/problem/${problem.displayId}`"
class="problem"
>
<span v-if="!compact" class="pid num">{{ problem.displayId }}</span>
<span class="ptitle">
<span class="ellipsis">{{ problem.title }}</span>
<span v-if="compact" class="sub">
<span class="num muted">{{ problem.displayId }}</span>
<span v-if="problem.mustDo" class="must">必会</span>
</span>
</span>
<span v-if="!compact && problem.mustDo" class="must">必会</span>
<span v-if="problem.solved" class="solved"
><Icon icon="ph:check-bold" :width="13" />做对过</span
>
<span v-else class="go">去做<Icon icon="ph:caret-right-bold" :width="12" /></span>
</router-link>
</section>
<section v-if="next && nextParts" class="card next">
<div class="next-text">
<div class="muted small">下一课 · 第 {{ step + 1 }} 课</div>
<div class="next-title">
<span class="next-kp">{{ nextParts.kp }}</span>
<span v-if="nextParts.scene" class="muted">{{ nextParts.scene }}</span>
</div>
</div>
<n-button
:type="finished ? 'primary' : 'default'"
size="large"
icon-placement="right"
tag="a"
:href="next.to"
@click.prevent="$router.push(next.to)"
>
下一课
<template #icon><Icon icon="ph:caret-right-bold" /></template>
</n-button>
</section>
</div>
</template>
<style scoped>
.end {
display: flex;
flex-direction: column;
gap: 16px;
margin: 28px 0 24px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.strong {
font-weight: 700;
font-size: 15px;
}
.num {
font-variant-numeric: tabular-nums;
}
.muted {
opacity: 0.6;
}
.small {
font-size: 12px;
}
.grow {
flex: 1;
}
.banner {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 18px 20px;
display: flex;
align-items: center;
gap: 16px;
}
.check {
width: 44px;
height: 44px;
border-radius: 22px;
flex-shrink: 0;
background: var(--learn-green);
color: #fff;
display: grid;
place-items: center;
}
.banner-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.banner-title {
font-size: 19px;
font-weight: 700;
}
.banner-progress {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 13px;
}
.practice {
padding: 14px 18px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.boxes {
display: flex;
gap: 4px;
}
.box {
width: 34px;
height: 8px;
border-radius: 4px;
background: var(--learn-todo);
}
.box.on {
background: var(--learn-green);
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.problems {
overflow: hidden;
}
.problems-head {
height: 46px;
padding: 0 16px;
display: flex;
align-items: center;
}
.problem {
min-height: 48px;
padding: 0 16px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.problem:hover {
background: var(--learn-soft);
}
.pid {
width: 40px;
font-size: 13px;
opacity: 0.6;
}
.ptitle {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-size: 14px;
padding: 6px 0;
}
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.must {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
background: var(--learn-orange-bg);
color: var(--learn-orange-text);
flex-shrink: 0;
}
.solved,
.go {
width: 64px;
justify-content: flex-end;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 13px;
font-weight: 600;
color: var(--learn-green-text);
}
.next {
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.next-text {
flex: 1;
min-width: 0;
}
.next-title {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.next-kp {
font-size: 17px;
font-weight: 700;
}
</style>
@@ -1,124 +0,0 @@
<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 }[]
step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */
progress: Record<number, TutorialProgress>
/** 是否在留痕(登录了才留) */
traced: boolean
}>()
const emit = defineEmits<{ select: [lesson: number] }>()
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>
<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)"
>
<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>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { OutlineItem } from "../composables/useLessonParse"
/**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
* 读到哪一节哪一节变绿。底下是这课练一练做到几道。
*/
const props = defineProps<{
items: OutlineItem[]
activeId: string
solvedIds: Set<number>
exerciseCount: number
}>()
const emit = defineEmits<{ jump: [id: string] }>()
const solvedCount = computed(
() =>
props.items.flatMap((item) => item.exerciseIds).filter((id) => props.solvedIds.has(id)).length,
)
function allSolved(item: OutlineItem) {
return item.exerciseIds.every((id) => props.solvedIds.has(id))
}
</script>
<template>
<nav class="outline" aria-label="这一课">
<div class="title">这一课</div>
<a
v-for="item in items"
:key="item.id"
:href="`#${item.id}`"
class="item"
:class="{ active: item.id === activeId }"
@click.prevent="emit('jump', item.id)"
>
<span class="mark" />
<span class="text">{{ item.text }}</span>
<span v-if="item.exerciseIds.length" class="ex" :class="{ ok: allSolved(item) }">
<Icon icon="ph:pencil-simple" :width="12" />
<Icon v-if="allSolved(item)" icon="ph:check-bold" :width="12" />
</span>
</a>
<div v-if="exerciseCount" class="practice">
<div class="practice-head">
<span>练一练</span>
<span class="num">{{ solvedCount }} / {{ exerciseCount }}</span>
</div>
<div class="boxes">
<span v-for="n in exerciseCount" :key="n" class="box" :class="{ on: n <= solvedCount }" />
</div>
</div>
</nav>
</template>
<style scoped>
.outline {
display: flex;
flex-direction: column;
}
.title {
font-size: 12px;
font-weight: 700;
opacity: 0.55;
margin-bottom: 6px;
}
.item {
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: inherit;
text-decoration: none;
opacity: 0.85;
}
.item:hover {
opacity: 1;
}
.item.active {
color: var(--learn-green-text);
font-weight: 700;
opacity: 1;
}
.mark {
width: 6px;
height: 6px;
border-radius: 3px;
flex-shrink: 0;
}
.active .mark {
background: var(--learn-green);
}
.text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ex {
display: inline-flex;
gap: 1px;
opacity: 0.5;
}
.ex.ok {
opacity: 1;
color: var(--learn-green-text);
}
.practice {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--learn-line);
}
.practice-head {
display: flex;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.num {
font-weight: 400;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.boxes {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.box {
width: 28px;
height: 8px;
border-radius: 4px;
background: var(--learn-todo);
}
.box.on {
background: var(--learn-green);
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { TutorialProgress } from "@oj2/contract"
import { lessonState, splitTitle } from "../lesson"
import LessonDot from "./LessonDot.vue"
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
const props = defineProps<{
title: string
step: number
to: string
progress?: TutorialProgress
active?: boolean
/** 行高,首页 48、目录 46、手机 50 */
height?: number
}>()
const emit = defineEmits<{ go: [] }>()
const parts = computed(() => splitTitle(props.title))
const state = computed(() => lessonState(props.progress))
</script>
<template>
<router-link
:to="to"
class="row"
:class="{ active }"
:style="{ height: `${height ?? 46}px` }"
@click="emit('go')"
>
<LessonDot :step="step" :state="state" :current="active" />
<span class="text">
<span class="kp">{{ parts.kp }}</span>
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
</span>
<span v-if="state === 'started' && progress?.exerciseTotal" class="count">
{{ progress.exerciseSolved }}/{{ progress.exerciseTotal }}
</span>
</router-link>
</template>
<style scoped>
.row {
box-sizing: border-box;
padding: 0 10px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
color: inherit;
text-decoration: none;
}
.row:hover {
background: var(--learn-soft);
}
.row.active {
background: var(--learn-green-bg);
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.kp,
.scene {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kp {
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.active .kp {
font-weight: 700;
}
.scene {
font-size: 12px;
line-height: 16px;
opacity: 0.55;
}
.count {
flex-shrink: 0;
font-size: 12px;
color: var(--learn-orange-text);
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,36 +0,0 @@
<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>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProjectGroup } from "../lesson"
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
defineProps<{ icon: string; tone: ProjectGroup["tone"]; size?: number }>()
</script>
<template>
<span
class="tile"
:class="tone"
:style="{ width: `${size ?? 36}px`, height: `${size ?? 36}px` }"
aria-hidden="true"
>
<Icon :icon="icon" :width="Math.round((size ?? 36) * 0.55)" />
</span>
</template>
<style scoped>
.tile {
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
}
.blue {
background: rgba(32, 128, 240, 0.12);
color: #1d5bb5;
}
.purple {
background: rgba(114, 46, 209, 0.1);
color: #5b3fa8;
}
.green {
background: rgba(24, 160, 88, 0.12);
color: #0c6b3b;
}
.orange {
background: rgba(240, 160, 32, 0.16);
color: #8a5200;
}
.gray {
background: rgba(128, 128, 128, 0.12);
color: inherit;
}
html.dark .blue {
color: #70c0e8;
}
html.dark .purple {
color: #b9a2f0;
}
html.dark .green {
color: #63e2b7;
}
html.dark .orange {
color: #f2c97d;
}
</style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProgress } from "@oj2/contract"
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from "../lesson"
import LessonRow from "./LessonRow.vue"
import ProjectTile from "./ProjectTile.vue"
/**
* 目录:按项目折叠,默认只展开正在学的那个项目(设计稿 A 的左栏、手机的目录抽屉)。
* `big` 是手机那版:项目行高一些、带图标。
*/
const props = defineProps<{
groups: ProjectGroup[]
progress: Record<number, TutorialProgress>
type: TutorialType
currentStep: number
big?: boolean
}>()
const emit = defineEmits<{ go: [] }>()
const open = ref(new Set<string>())
function keyOf(group: ProjectGroup) {
return `${group.project}:${group.lessons[0]!.id}`
}
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
watch(
() => [props.groups, props.currentStep] as const,
([groups, step]) => {
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step))
if (group) open.value = new Set([...open.value, keyOf(group)])
},
{ immediate: true },
)
function toggle(group: ProjectGroup) {
const next = new Set(open.value)
const key = keyOf(group)
if (next.has(key)) next.delete(key)
else next.add(key)
open.value = next
}
function doneIn(group: ProjectGroup) {
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === "done").length
}
</script>
<template>
<div class="tree" :class="{ big }">
<template v-for="group in groups" :key="keyOf(group)">
<button type="button" class="group" @click="toggle(group)">
<ProjectTile v-if="big" :icon="group.icon" :tone="group.tone" :size="30" />
<span v-else class="chev">
<Icon
:icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="13"
/>
</span>
<span class="name">
<span v-if="big && group.no" class="no">{{ group.no }}</span>
<span class="label">
<span v-if="!big && group.no" class="no-inline">{{ group.no.slice(2) }}</span>
{{ group.name }}
</span>
</span>
<span v-if="doneIn(group) === group.lessons.length" class="count all">
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }}
</span>
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span>
<span v-if="big" class="chev">
<Icon
:icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="14"
/>
</span>
</button>
<div v-if="open.has(keyOf(group))" class="lessons">
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(type, lesson.step)"
:progress="progress[lesson.id]"
:active="lesson.step === currentStep"
:height="big ? 50 : 46"
@go="emit('go')"
/>
</div>
</template>
</div>
</template>
<style scoped>
.tree {
display: flex;
flex-direction: column;
gap: 2px;
}
.group {
height: 36px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
text-align: left;
}
.group:hover {
background: var(--learn-soft);
}
.big .group {
height: 52px;
gap: 10px;
border-top: 1px solid var(--learn-line);
border-radius: 0;
}
.chev {
display: flex;
opacity: 0.5;
}
.name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.label {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.big .label {
font-size: 14px;
font-weight: 700;
}
.no {
font-size: 11px;
opacity: 0.55;
}
.no-inline {
font-weight: 400;
opacity: 0.5;
margin-right: 6px;
}
.count {
flex-shrink: 0;
font-size: 12px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
.lessons {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 6px;
}
.big .lessons {
padding: 0 0 8px;
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { LessonState, ProjectGroup } from "../lesson"
/**
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
* 当前这课描一圈。
*/
defineProps<{
groups: ProjectGroup[]
stateOf: (id: number) => LessonState
current?: number
/** 每段的宽、高 */
width?: number
height?: number
gap?: number
groupGap?: number
}>()
</script>
<template>
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
<div
v-for="group in groups"
:key="group.lessons[0]!.id"
class="group"
:style="{ gap: `${gap ?? 3}px` }"
>
<span
v-for="lesson in group.lessons"
:key="lesson.id"
class="cell"
:class="[stateOf(lesson.id), { current: lesson.id === current }]"
:style="{
width: `${width ?? 20}px`,
height: `${height ?? 6}px`,
borderRadius: `${(height ?? 6) / 2}px`,
}"
/>
</div>
</div>
</template>
<style scoped>
.seg,
.group {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.cell {
flex-shrink: 0;
background: var(--learn-todo);
}
.cell.done {
background: var(--learn-green);
}
.cell.started {
background: var(--learn-orange);
}
.cell.current {
outline: 2px solid var(--learn-green-text);
outline-offset: 1px;
}
</style>
@@ -1,32 +0,0 @@
import type { Exercise } from "utils/types"
export type Segment = { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises(content: string, exercises: Exercise[]): Segment[] {
const exerciseMap = new Map(exercises.map((e) => [e.id, e]))
const segments: Segment[] = []
const regex = /\[\[exercise:(\d+)\]\]/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "md",
content: content.slice(lastIndex, match.index),
})
}
const id = parseInt(match[1])
const exercise = exerciseMap.get(id)
if (exercise) {
segments.push({ type: "exercise", exercise })
}
lastIndex = regex.lastIndex
}
if (lastIndex < content.length) {
segments.push({ type: "md", content: content.slice(lastIndex) })
}
return segments
}
@@ -0,0 +1,158 @@
import type { Exercise } from "utils/types"
/**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
*
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
* - `md`:其余的交给 MdPreview。
*
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/
export type LessonSegment =
| { type: "md"; content: string }
| { type: "heading"; id: string; text: string }
| { type: "goals"; id: string; items: string[] }
| { type: "code"; lang: "python" | "c"; code: string; index: number }
| { type: "exercise"; exercise: Exercise; index: number }
/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */
export interface OutlineItem {
id: string
text: string
exerciseIds: number[]
}
const CODE_LANGS: Record<string, "python" | "c"> = {
python: "python",
py: "python",
python3: "python",
c: "c",
cpp: "c",
"c++": "c",
}
const EXERCISE_RE = /\[\[exercise:(\d+)\]\]/g
const GOALS_TITLE = "学习目标"
/** 去掉开头的标题、所属项目、项目配图(中间的空行一起) */
function stripPreamble(lines: string[]) {
let i = 0
const skipBlank = () => {
while (i < lines.length && !lines[i]!.trim()) i++
}
skipBlank()
if (lines[i]?.startsWith("# ")) i++
skipBlank()
if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? "")) {
i++
skipBlank()
if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? "")) i++
}
return lines.slice(i)
}
export function parseLesson(content: string, exercises: Exercise[]) {
const byId = new Map(exercises.map((item) => [item.id, item]))
const segments: LessonSegment[] = []
const outline: OutlineItem[] = []
let md: string[] = []
let codeIndex = 0
let exerciseIndex = 0
let headingIndex = 0
const flush = () => {
const text = md.join("\n")
if (text.trim()) segments.push({ type: "md", content: text })
md = []
}
const lines = stripPreamble(content.replace(/\r\n/g, "\n").split("\n"))
let i = 0
while (i < lines.length) {
const line = lines[i]!
const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/)
if (fence) {
const marker = fence[2]!
const lang = CODE_LANGS[fence[3]!.toLowerCase()]
const body: string[] = []
let j = i + 1
while (j < lines.length && !lines[j]!.trim().startsWith(marker)) body.push(lines[j++]!)
if (lang) {
flush()
segments.push({ type: "code", lang, code: body.join("\n"), index: codeIndex++ })
} else {
md.push(line, ...body, lines[j] ?? marker)
}
i = j + 1
continue
}
const heading = line.match(/^##\s+(.+?)\s*#*\s*$/)
if (heading) {
flush()
const text = heading[1]!.trim()
const id = `section-${headingIndex++}`
if (text === GOALS_TITLE) {
// 学习目标:收到下一个分隔线或标题为止,只要列表项
const items: string[] = []
let j = i + 1
while (j < lines.length && !/^(---+|##\s)/.test(lines[j]!.trim())) {
const item = lines[j]!.match(/^\s*[-*+]\s+(.+)$/)
if (item) items.push(item[1]!.trim())
j++
}
segments.push({ type: "goals", id, items })
outline.push({ id, text: "学完这课你能", exerciseIds: [] })
i = j
continue
}
segments.push({ type: "heading", id, text })
outline.push({ id, text, exerciseIds: [] })
i++
continue
}
// 节与节之间的分隔线:二级标题本身就隔开了,再画一道线是重复
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
i++
continue
}
if (EXERCISE_RE.test(line)) {
EXERCISE_RE.lastIndex = 0
let last = 0
for (const match of line.matchAll(EXERCISE_RE)) {
md.push(line.slice(last, match.index))
last = match.index! + match[0].length
const exercise = byId.get(Number(match[1]))
if (!exercise) continue
flush()
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
md.push(line.slice(last))
i++
continue
}
md.push(line)
i++
}
flush()
// 正文里没放占位符的练一练接在最后:学完一课要全做对,看不见的题会让这课永远学不完
const placed = new Set(
segments.flatMap((item) => (item.type === "exercise" ? [item.exercise.id] : [])),
)
for (const exercise of exercises) {
if (placed.has(exercise.id)) continue
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
return { segments, outline, exerciseCount: exerciseIndex }
}
+575
View File
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProgress, TutorialSummary } from "@oj2/contract"
import { getLearnProgress, getTutorials } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import {
COURSES,
continueStep,
doneCount,
groupByProject,
lessonPath,
lessonState,
splitProject,
splitTitle,
type TutorialType,
} from "./lesson"
import LessonRow from "./components/LessonRow.vue"
import ProjectTile from "./components/ProjectTile.vue"
import ProjectTree from "./components/ProjectTree.vue"
import SegBar from "./components/SegBar.vue"
import "./learn.css"
/**
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
*
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
* 轮着好几个班用,本地记的是上一个人学到哪。
*/
const userStore = useUserStore()
const authModal = useAuthModalStore()
const { isDesktop } = useBreakpoints()
const route = useRoute()
const router = useRouter()
interface Course {
type: TutorialType
label: string
titles: TutorialSummary[]
progress: Record<number, TutorialProgress>
}
const courses = ref<Course[]>([])
const loaded = ref(false)
const tab = ref<TutorialType>(route.query.course === "c" ? "c" : "python")
async function load() {
const authed = userStore.isAuthed
courses.value = await Promise.all(
COURSES.map(async ({ type, label }) => {
const [titles, rows] = await Promise.all([
getTutorials(type),
authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([]),
])
return {
type,
label,
titles,
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
}
}),
)
// 没指定看哪门课时,打开最近在学的那门
if (!route.query.course && resume.value) tab.value = resume.value.course.type
loaded.value = true
}
function latestViewed(course: Course) {
return Object.values(course.progress).reduce<string>(
(acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc),
"",
)
}
/** 接着学:最近打开过的那门课里该接着学的那课 */
const resume = computed(() => {
const started = courses.value
.filter((course) => course.titles.length && latestViewed(course))
.sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
const course = started[0]
if (!course) return null
const step = continueStep(course.titles, course.progress)
if (!step) return null
const item = course.titles[step - 1]!
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
})
const others = computed(() =>
courses.value.filter(
(course) => course.titles.length && course.type !== resume.value?.course.type,
),
)
const current = computed(() => courses.value.find((course) => course.type === tab.value))
const groups = computed(() => groupByProject(current.value?.titles ?? []))
function stateOf(course: Course) {
return (id: number) => lessonState(course.progress[id])
}
function firstLesson(course: Course) {
return course.titles[0] ? splitTitle(course.titles[0].title) : null
}
function courseStep(course: Course) {
return continueStep(course.titles, course.progress)
}
function pickTab(type: TutorialType) {
tab.value = type
router.replace({ query: { ...route.query, course: type } })
}
function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => lessonState(course.progress[id]) === "done").length
}
watch(() => userStore.isAuthed, load)
onMounted(load)
</script>
<template>
<div class="home">
<template v-if="loaded">
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
<div class="top" :class="{ two: !resume }">
<section v-if="resume" class="resume">
<div class="resume-main">
<div class="resume-meta">
<span class="resume-label">接着学</span>
<span class="muted">
{{ resume.course.label }}
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
· 第 {{ resume.step }} 课
</span>
</div>
<div class="resume-title">
<span class="resume-kp">{{ resume.parts.kp }}</span>
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
</div>
<div class="resume-progress">
<SegBar
:groups="groupByProject(resume.course.titles)"
:state-of="stateOf(resume.course)"
:current="resume.item.id"
:width="isDesktop ? 20 : 11"
:gap="isDesktop ? 3 : 2"
:group-gap="isDesktop ? 10 : 6"
/>
<span class="num sec">
学完 {{ doneCount(resume.course.titles, resume.course.progress) }} /
{{ resume.course.titles.length }} 课
</span>
</div>
</div>
<n-button
type="primary"
size="large"
class="resume-go"
icon-placement="right"
@click="router.push(lessonPath(resume.course.type, resume.step))"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
接着学
</n-button>
</section>
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
<section
v-for="course in resume
? isDesktop
? others
: []
: courses.filter((c) => c.titles.length)"
:key="course.type"
class="course card"
>
<div class="course-text">
<div class="course-head">
<span class="course-name">{{ course.label }}</span>
<span class="muted small">
{{ course.titles.length }} 课
<template v-if="resume">
·
{{
courseStep(course)
? `学完 ${doneCount(course.titles, course.progress)} 课`
: "还没开始"
}}</template
>
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
</span>
</div>
<div v-if="!resume && firstLesson(course)" class="first">
<span class="muted">第 1 课</span>
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
<span class="sec">{{ firstLesson(course)!.scene }}</span>
</div>
<SegBar
:groups="groupByProject(course.titles)"
:state-of="stateOf(course)"
:width="resume ? 12 : 14"
/>
<router-link
v-if="resume"
:to="lessonPath(course.type, courseStep(course) ?? 1)"
class="course-link"
>
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : "从第 1 课开始" }}
<Icon icon="ph:caret-right-bold" :width="13" />
</router-link>
</div>
<n-button
v-if="!resume"
type="primary"
icon-placement="right"
@click="router.push(lessonPath(course.type, 1))"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
开始学
</n-button>
</section>
</div>
<div class="tabs" role="tablist">
<button
v-for="course in courses.filter((c) => c.titles.length)"
:key="course.type"
type="button"
role="tab"
class="tab"
:class="{ on: tab === course.type }"
:aria-selected="tab === course.type"
@click="pickTab(course.type)"
>
{{ course.label }}
<span class="tab-sub">
{{
userStore.isAuthed && doneCount(course.titles, course.progress)
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
: `${course.titles.length} 课`
}}
</span>
</button>
<span class="grow" />
<button
v-if="!userStore.isAuthed"
type="button"
class="login"
@click="authModal.openLoginModal()"
>
登录后记下学到哪
</button>
</div>
<div v-if="current && isDesktop" class="map">
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
<div class="project-head">
<ProjectTile :icon="group.icon" :tone="group.tone" />
<span class="project-name">
<span v-if="group.no" class="muted small">{{ group.no }}</span>
<span class="project-title">{{ group.name }}</span>
</span>
<span
v-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
) === group.lessons.length
"
class="project-count all"
>
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{
group.lessons.length
}}
</span>
<span
v-else-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
)
"
class="project-count"
>
{{
doneIn(
current,
group.lessons.map((l) => l.id),
)
}}/{{ group.lessons.length }}
</span>
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
</div>
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(current.type, lesson.step)"
:progress="current.progress[lesson.id]"
:active="resume?.course.type === current.type && resume.step === lesson.step"
:height="48"
/>
</section>
</div>
<ProjectTree
v-else-if="current"
:groups="groups"
:progress="current.progress"
:type="current.type"
:current-step="resume?.course.type === current.type ? resume.step : 0"
big
/>
<n-empty
v-if="!courses.some((c) => c.titles.length)"
description="还没有公开的教程"
style="margin-top: 80px"
/>
</template>
<n-spin v-else class="spin" />
</div>
</template>
<style scoped>
.home {
max-width: 1286px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.muted {
opacity: 0.6;
}
.sec {
opacity: 0.8;
}
.small {
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.top {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px;
}
.top.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resume {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 20px 26px;
display: flex;
align-items: center;
gap: 24px;
}
.resume-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.resume-meta {
display: flex;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.resume-label {
color: var(--learn-green-text);
font-weight: 700;
}
.resume-title {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.resume-kp {
font-size: 26px;
font-weight: 700;
}
.resume-scene {
font-size: 15px;
opacity: 0.8;
}
.resume-progress {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 13px;
}
.course {
padding: 18px 22px;
display: flex;
align-items: center;
gap: 20px;
}
.course-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.course-name {
font-size: 18px;
font-weight: 700;
}
.first {
display: flex;
gap: 6px;
font-size: 14px;
flex-wrap: wrap;
}
.first-kp {
font-weight: 600;
}
.course-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.tabs {
display: flex;
align-items: stretch;
gap: 24px;
height: 40px;
border-bottom: 1px solid var(--learn-line);
}
.tab {
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: inherit;
font: inherit;
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0;
}
.tab.on {
color: var(--learn-green-text);
font-weight: 700;
border-bottom-color: var(--learn-green);
}
.tab-sub {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.login {
border: 0;
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.map {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.map {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top {
grid-template-columns: minmax(0, 1fr);
}
}
.project {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.project-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 10px;
}
.project-name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.project-title {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-count {
font-size: 13px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.project-count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
@media (max-width: 768px) {
.home {
gap: 14px;
}
.top,
.top.two {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.resume {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
}
.resume-kp {
font-size: 22px;
}
.resume-go {
width: 100%;
}
.course {
padding: 14px 16px;
}
.tabs {
gap: 20px;
}
}
</style>
+522 -150
View File
@@ -1,129 +1,97 @@
<template>
<div class="learn-container">
<template v-if="tutorial.id">
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
<aside class="rail">
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</aside>
<main class="reader">
<article class="reader-body">
<header class="lesson-head">
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
<n-flex align="center" justify="space-between" :wrap="false">
<span />
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
</n-button>
</n-flex>
</header>
<LessonBody :segments="segments" :lang="tutorial.type" />
</article>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</main>
<aside v-if="tutorial.code && codeOpen" class="code-panel">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
</aside>
</div>
<!-- 手机端 -->
<template v-else>
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<LessonBody :segments="segments" :lang="tutorial.type" />
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</template>
</template>
<n-empty v-if="isEmpty" description="该教程还没有公开" style="margin-top: 80px" />
</div>
</template>
<script setup lang="ts">
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { Tutorial, TutorialProgress, TutorialSummary } from "@oj2/contract"
import type { Exercise } from "utils/types"
import { getExercises, getLearnProgress, getTutorial, getTutorials } from "../api"
import { parseLesson } from "./composables/useLessonParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
import {
COURSES,
doneCount,
groupByProject,
lessonPath,
lessonState,
splitTitle,
type TutorialType,
} from "./lesson"
import LessonBody from "./components/LessonBody.vue"
import PagerBar from "./components/PagerBar.vue"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
import LessonEnd from "./components/LessonEnd.vue"
import LessonOutline from "./components/LessonOutline.vue"
import ProjectTree from "./components/ProjectTree.vue"
import SegBar from "./components/SegBar.vue"
import "./learn.css"
/**
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
*/
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const userStore = useUserStore()
const theme = useThemeVars()
// 未登录也能看教程(学习页本来就不要求登录),只是不留痕
// 未登录也能看教程,只是不留痕、不记进度
const traced = computed(() => userStore.isAuthed)
const type = computed<TutorialType>(() => (route.params.type === "c" ? "c" : "python"))
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
const step = computed(() => {
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
const value = parseInt((route.params.step as string | undefined) ?? "1")
return Number.isFinite(value) && value > 0 ? value : 1
})
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
const titles = ref<{ id: number; title: string }[]>([])
const titles = ref<TutorialSummary[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const tutorial = ref<Tutorial | null>(null)
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
// 示例代码栏默认展开,收起后正文独占版面;偏好记在本机
const codeOpen = useStorage("oj2:learn-code-open", true)
const solvedIds = ref(new Set<number>())
const loading = ref(true)
const isEmpty = ref(false)
const menuOpen = ref(false)
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
const groups = computed(() => groupByProject(titles.value))
const parsed = computed(() =>
tutorial.value
? parseLesson(tutorial.value.content, exercises.value)
: { segments: [], outline: [], exerciseCount: 0 },
)
const parts = computed(() => splitTitle(tutorial.value?.title ?? ""))
const solvedCount = computed(
() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length,
)
const firstUnsolved = computed(() => {
for (const seg of parsed.value.segments) {
if (seg.type === "exercise" && !solvedIds.value.has(seg.exercise.id)) {
return { id: seg.exercise.id, index: seg.index + 1 }
}
}
return null
})
const prev = computed(() => lessonAt(step.value - 1))
const next = computed(() => lessonAt(step.value + 1))
const done = computed(() => doneCount(titles.value, progress.value))
function lessonAt(at: number) {
const item = titles.value[at - 1]
return item ? { title: item.title, to: lessonPath(type.value, at) } : null
}
function stateOf(id: number) {
return lessonState(progress.value[id])
}
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value.id ?? 0),
computed(() => tutorial.value?.id ?? 0),
traced,
)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
@@ -139,85 +107,489 @@ async function loadProgress() {
}
}
let generation = 0
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1
isEmpty.value = titles.value.length === 0
if (isEmpty.value) return
const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
if (res2.status === "fulfilled") tutorial.value = res2.value
const mine = ++generation
loading.value = true
menuOpen.value = false
const list = await getTutorials(type.value)
if (mine !== generation) return
titles.value = list
isEmpty.value = list.length === 0
if (isEmpty.value) {
loading.value = false
return
}
if (step.value > list.length) {
router.replace(lessonPath(type.value, list.length))
return
}
const id = list[step.value - 1]!.id
const [lesson, exs] = await Promise.allSettled([
getTutorial(id),
getExercises(id),
loadProgress(),
])
if (mine !== generation) return
tutorial.value = lesson.status === "fulfilled" ? lesson.value : null
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
loadProgress()
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
loading.value = false
activeId.value = ""
await nextTick()
reader.value?.scrollTo({ top: 0 })
if (!isDesktop.value) window.scrollTo({ top: 0 })
}
watch(
() => [route.params.type, route.params.step],
async () => {
() => {
if (route.name === "learn") init()
},
{ immediate: true },
)
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
watch(traced, loadProgress)
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
watch(traced, async () => {
await loadProgress()
if (!tutorial.value) return
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
exercises.value = exs
solvedIds.value = new Set([
...solvedIds.value,
...exs.filter((item) => item.solved).map((item) => item.id),
])
})
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
function onSolved(exerciseId: number) {
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
solvedIds.value = new Set([...solvedIds.value, exerciseId])
const id = tutorial.value.id
const row = progress.value[id] ?? {
tutorialId: id,
viewCount: 1,
totalSeconds: 0,
firstViewedAt: null,
lastViewedAt: null,
exerciseTotal: exercises.value.length,
exerciseSolved: 0,
}
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref("")
function onScroll() {
const box = reader.value
if (!box) return
const top = box.getBoundingClientRect().top + 160
let current = parsed.value.outline[0]?.id ?? ""
for (const item of parsed.value.outline) {
const el = document.getElementById(item.id)
if (el && el.getBoundingClientRect().top <= top) current = item.id
}
activeId.value = current
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
}
function jumpExercise(id: number) {
jump(`exercise-${id}`)
}
</script>
<template>
<div class="learn">
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
<!-- 桌面:目录 | 正文 | 这一课 -->
<div v-else-if="isDesktop" class="layout">
<aside class="rail">
<router-link to="/learn" class="back">
<Icon icon="ph:caret-left-bold" :width="13" />自学首页
</router-link>
<div class="course">
<div class="course-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
</div>
<SegBar
:groups="groups"
:state-of="stateOf"
:current="tutorial?.id"
:width="9"
:height="5"
:gap="2"
:group-gap="6"
/>
</div>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
</aside>
<main class="main">
<div ref="reader" class="reader" @scroll.passive="onScroll">
<article v-if="tutorial" class="article">
<div class="crumb muted">
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<LessonBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
</div>
<nav class="pager">
<div class="pager-inner">
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
</n-button>
<span class="grow" />
<n-button
v-if="next"
type="primary"
icon-placement="right"
tag="a"
:href="next.to"
@click.prevent="router.push(next.to)"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
</n-button>
</div>
</nav>
</main>
<aside class="side">
<LessonOutline
v-if="tutorial"
:items="parsed.outline"
:active-id="activeId || parsed.outline[0]?.id || ''"
:solved-ids="solvedIds"
:exercise-count="parsed.exerciseCount"
@jump="jump"
/>
</aside>
</div>
<!-- 手机 -->
<div v-else class="mobile">
<div class="mbar">
<n-button @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
<span class="grow" />
<span v-if="parsed.exerciseCount" class="mpractice">
<span>练一练</span>
<span class="mboxes">
<span
v-for="n in parsed.exerciseCount"
:key="n"
class="mbox"
:class="{ on: n <= solvedCount }"
/>
</span>
</span>
</div>
<article v-if="tutorial" class="marticle">
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
<h1 class="h1 small">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<LessonBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
<nav class="mpager">
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课
</n-button>
<span class="grow" />
<n-button size="large" quaternary @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="grow" />
<n-button
v-if="next"
size="large"
type="primary"
icon-placement="right"
@click="router.push(next.to)"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课
</n-button>
</nav>
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet">
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
<router-link to="/learn" class="sheet-home">自学首页</router-link>
</span>
</template>
<ProjectTree
:groups="groups"
:progress="progress"
:type="type"
:current-step="step"
big
@go="menuOpen = false"
/>
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
/* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 134px);
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
/* 桌面端固定高度,三栏各自滚动 */
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 220px;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 134px);
}
@media (max-width: 1180px) {
.layout {
grid-template-columns: 240px minmax(0, 1fr);
}
.side {
display: none;
}
}
.learn-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 24px;
height: 100%;
}
.learn-layout.with-code {
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
}
.rail,
.reader {
.rail {
box-sizing: border-box;
border-right: 1px solid var(--learn-line);
padding: 0 12px 16px 0;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.reader {
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
padding: 0 8px;
color: var(--learn-green-text);
text-decoration: none;
}
.course {
padding: 10px 8px 12px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.course-name {
font-size: 17px;
font-weight: 700;
}
.login-tip {
font-size: 13px;
padding: 8px;
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
}
.reader-body {
.reader {
flex: 1;
width: 100%;
max-width: 820px;
min-height: 0;
overflow-y: auto;
padding: 4px 40px 0;
}
.article {
max-width: 720px;
margin: 0 auto;
}
.reader :deep(.pager) {
max-width: 820px;
width: 100%;
margin-left: auto;
margin-right: auto;
.crumb {
display: flex;
gap: 8px;
font-size: 13px;
margin-bottom: 6px;
}
.lesson-head h1,
.mobile-title {
margin: 4px 0 12px;
font-size: 26px;
.h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.mobile-title {
font-size: 20px;
.h1.small {
font-size: 24px;
}
.scene {
font-size: 15px;
opacity: 0.8;
margin: 4px 0 18px;
}
.pager {
flex-shrink: 0;
border-top: 1px solid var(--learn-line);
padding: 12px 40px;
}
.pager-inner {
max-width: 720px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.pager-name {
margin-left: 6px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-name {
opacity: 0.85;
}
.side {
box-sizing: border-box;
padding: 24px 0 0 16px;
overflow-y: auto;
}
.code-panel {
height: 100%;
overflow: hidden;
border-radius: 8px;
/* 手机 */
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
.mobile {
padding-bottom: 72px;
}
.mbar {
height: 48px;
margin: 0 -12px;
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid var(--learn-line);
background: v-bind("theme.bodyColor");
display: flex;
align-items: center;
gap: 8px;
}
.muted-strong {
font-size: 13px;
opacity: 0.8;
}
.mpractice {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.mboxes {
display: flex;
gap: 3px;
}
.mbox {
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--learn-todo);
}
.mbox.on {
background: var(--learn-green);
}
.marticle {
padding-top: 16px;
}
.mpager {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 12px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
background: v-bind("theme.bodyColor");
}
.sheet-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.sheet-home {
margin-left: 12px;
color: var(--learn-green-text);
text-decoration: none;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
/* 自学模块的几个颜色。深色模式下深绿、深橙的字在暗底上读不出来,换成亮一档的。
设计稿在「自学重设计」画布页(Learn*.dc.html)。 */
:root {
--learn-green: #18a058;
--learn-green-text: #0c7a43;
--learn-green-bg: rgba(24, 160, 88, 0.1);
--learn-green-line: rgba(24, 160, 88, 0.35);
--learn-orange: #f0a020;
--learn-orange-text: #8a5200;
--learn-orange-bg: rgba(240, 160, 32, 0.14);
--learn-red-text: #b0263c;
--learn-line: rgba(128, 128, 128, 0.22);
--learn-soft: rgba(128, 128, 128, 0.07);
--learn-pane: #ffffff;
--learn-todo: rgba(128, 128, 128, 0.22);
--learn-code-font: Consolas, Monaco, "Microsoft YaHei", monospace;
}
html.dark {
--learn-green-text: #63e2b7;
--learn-orange-text: #f2c97d;
--learn-red-text: #f08a9b;
--learn-pane: rgba(255, 255, 255, 0.03);
}
+117
View File
@@ -0,0 +1,117 @@
import { isLessonDone, type TutorialProgress, type TutorialSummary } from "@oj2/contract"
export type TutorialType = "python" | "c"
export const COURSES: { type: TutorialType; label: string }[] = [
{ type: "python", label: "Python" },
{ type: "c", label: "C 语言" },
]
/**
* 标题是「情境——知识点」(「蜜橘园环境预警——条件语句基础」)。学生找的是知识点,
* 所以显示时知识点在前、情境在后。没有「——」的老教程整个当知识点。
*/
export function splitTitle(title: string) {
const at = title.indexOf("——")
if (at < 0) return { kp: title.trim(), scene: "" }
return { kp: title.slice(at + 2).trim(), scene: title.slice(0, at).trim() }
}
/** 编辑时拆开的两半拼回去 */
export function joinTitle(kp: string, scene: string) {
return scene.trim() ? `${scene.trim()}——${kp.trim()}` : kp.trim()
}
export interface ProjectGroup {
/** 原样的项目名,「项目三 · 智慧农业·蜜橘园」;没填是空串 */
project: string
/** 「项目三」,没有这一截时是空串 */
no: string
/** 「智慧农业·蜜橘园」 */
name: string
icon: string
tone: "blue" | "purple" | "green" | "orange" | "gray"
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
lessons: { id: number; title: string; step: number }[]
}
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
export function splitProject(project: string) {
const match = project.match(/^(项目[一二三四五六七八九十\d]+)\s*[·・.]\s*(.+)$/)
if (!match) return { no: "", name: project || "其他" }
return { no: match[1]!, name: match[2]!.trim() }
}
/** 项目图标按情境挑,认不出的给一本书 */
function projectLook(name: string): Pick<ProjectGroup, "icon" | "tone"> {
if (name.includes("家居")) return { icon: "ph:house", tone: "blue" }
if (name.includes("工业") || name.includes("设备")) return { icon: "ph:factory", tone: "purple" }
if (name.includes("农业")) return { icon: "ph:plant", tone: "green" }
if (name.includes("交通")) return { icon: "ph:car", tone: "orange" }
return { icon: "ph:book-open", tone: "gray" }
}
/** 按项目分组,保持课程原来的顺序;同一个项目被别的项目隔开时各算一组 */
export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
const groups: ProjectGroup[] = []
titles.forEach((item, index) => {
const last = groups[groups.length - 1]
if (last && last.project === item.project) {
last.lessons.push({ id: item.id, title: item.title, step: index + 1 })
return
}
const { no, name } = splitProject(item.project)
groups.push({
project: item.project,
no,
name,
...projectLook(name),
lessons: [{ id: item.id, title: item.title, step: index + 1 }],
})
})
return groups
}
export type LessonState = "done" | "started" | "todo"
/**
* 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没
* 全对是「在学」;别的是没开始。
*/
export function lessonState(progress: TutorialProgress | undefined): LessonState {
if (!progress) return "todo"
if (isLessonDone(progress)) return "done"
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return "started"
return "todo"
}
/**
* 「接着学」哪一课:最近打开的那课;它已经学完了,就往后找第一课没学完的。
* 一课没打开过返回 null(首页换成「开始学」)。
*/
export function continueStep(
titles: TutorialSummary[],
progress: Record<number, TutorialProgress>,
) {
let latest: TutorialProgress | null = null
for (const item of titles) {
const row = progress[item.id]
if (row?.lastViewedAt && (!latest || row.lastViewedAt > latest.lastViewedAt!)) latest = row
}
if (!latest) return null
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
if (index < 0) return null
if (lessonState(latest) !== "done") return index + 1
for (let i = index + 1; i < titles.length; i++) {
if (lessonState(progress[titles[i]!.id]) !== "done") return i + 1
}
return index + 1
}
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
return titles.filter((item) => lessonState(progress[item.id]) === "done").length
}
export function lessonPath(type: TutorialType, step: number) {
return `/learn/${type}/${step.toString().padStart(2, "0")}`
}
+5
View File
@@ -121,6 +121,11 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/user/message.vue"),
meta: { requiresAuth: true },
},
{
path: "learn",
name: "learn home",
component: () => import("oj/learn/home.vue"),
},
{
path: "learn/:type/:step",
name: "learn",
+5 -25
View File
@@ -4,7 +4,6 @@ import { NSwitch, useThemeVars } from "naive-ui"
import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "../store/config"
@@ -20,7 +19,6 @@ const route = useRoute()
const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars()
@@ -77,16 +75,12 @@ function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 18 })
}
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
/**
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。
*
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名。「自学」是下拉,单独渲染在最前面,
* 不在这张表里。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名。「自学」进自学首页(/learn),
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 公告不上顶栏 —— 一年五六条、基本是版本更新,首页有公告卡片和「全部」入口;
* 只有手机菜单里留一项(见 mobileMenus)。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
@@ -100,6 +94,7 @@ interface NavLink {
const navLinks = computed<NavLink[]>(() =>
[
{ key: "learn", label: "自学", to: "/learn" },
{ key: "problem", label: "题目", to: "/problem" },
{ key: "submission", label: "提交", to: "/submission", show: userStore.showSubmissions },
{ key: "problemset", label: "题单", to: "/problemset" },
@@ -110,24 +105,14 @@ const navLinks = computed<NavLink[]>(() =>
const announcementLink: NavLink = { key: "announcement", label: "公告", to: "/announcement" }
const learnOptions: DropdownOption[] = [
{ label: "Python", key: "learn-python", icon: renderIcon("ph:book-open") },
{ label: "C 语言", key: "learn-c", icon: renderIcon("ph:book-open") },
]
const mobileMenus = computed<DropdownOption[]>(() => [
{ label: "自学", key: "learn", children: learnOptions },
...navLinks.value.map((link) => ({ label: link.label, key: link.key })),
{ label: announcementLink.label, key: announcementLink.key },
])
function handleNavSelect(key: string) {
if (key === "learn-python") router.push(learnLink("python"))
else if (key === "learn-c") router.push(learnLink("c"))
else {
const link = [...navLinks.value, announcementLink].find((item) => item.key === key)
if (link) router.push(link.to)
}
const link = [...navLinks.value, announcementLink].find((item) => item.key === key)
if (link) router.push(link.to)
}
const adminPath = computed(() => (userStore.isSuperAdmin ? "/admin" : "/admin/problem/list"))
@@ -236,11 +221,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
</n-tag>
</RouterLink>
<nav v-if="isDesktop" class="nav">
<n-dropdown trigger="hover" :options="learnOptions" @select="handleNavSelect">
<button type="button" class="nav-link" :class="{ active: active === 'learn' }">
自学<Icon icon="ph:caret-down-bold" :width="11" />
</button>
</n-dropdown>
<RouterLink
v-for="link in navLinks"
:key="link.key"
@@ -1,17 +1,44 @@
<template>
<MdEditor :theme="isDark ? 'dark' : 'light'" v-model="modelValue" @onUploadImg="onUploadImg" />
<MdEditor
ref="editor"
:theme="isDark ? 'dark' : 'light'"
v-model="modelValue"
:preview="preview"
:style="height ? { height } : undefined"
@onUploadImg="onUploadImg"
/>
</template>
<script setup lang="ts">
import { MdEditor } from "md-editor-v3"
import { MdEditor, type ExposeParam } from "md-editor-v3"
import "md-editor-v3/lib/style.css"
import { uploadImage } from "../../admin/api"
/**
* `preview` 关掉自带的预览:教程编辑页右边要用学生端同一套组件预览(练一练、能运行的
* 代码块),md-editor 自己那份预览认不出 `[[exercise:N]]`。
*/
const { preview = true, height } = defineProps<{ preview?: boolean; height?: string }>()
const isDark = useDark()
const editor = useTemplateRef<ExposeParam>("editor")
const modelValue = defineModel<string>("value")
const message = useMessage()
/** 在光标处插一段文字(教程编辑页插练一练占位符用),自成一行 */
function insert(text: string) {
editor.value?.insert(() => ({
targetValue: `\n${text}\n`,
select: false,
deviationStart: 0,
deviationEnd: 0,
}))
}
defineExpose({ insert })
const onUploadImg = async (files: File[], callback: (urls: string[]) => void) => {
try {
const res = await Promise.all(
@@ -1,15 +0,0 @@
import { useStorage } from "@vueuse/core"
import { STORAGE_KEY } from "utils/constants"
export type TutorialType = "python" | "c"
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
// 否则导航栏的链接不会随学习进度更新
const learnStep = useStorage<Record<TutorialType, number>>(STORAGE_KEY.LEARN_CURRENT_STEP, {
python: 1,
c: 1,
})
export function useLearnProgress() {
return { learnStep }
}
-1
View File
@@ -150,7 +150,6 @@ export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export const STORAGE_KEY = {
AUTHED: "authed",
LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_CODE_PROBLEM: "adminCodeProblem",
+10 -10
View File
@@ -329,10 +329,8 @@ export type {
AdminAiReportList,
StuckProblem,
AcTrend,
LearnStudentProgress,
LearnStudentProgressList,
LearnTutorialProgress,
LearnTutorialProgressList,
LearnBoard,
LearnBoardStudent,
LearnExerciseProgress,
LearnExerciseProgressList,
LearnExerciseAttempt,
@@ -390,13 +388,13 @@ import type { AdminExercise, AdminTutorial, CreateSubmissionRequest } from "@oj2
/**
* 教程编辑表单。只留可编辑字段 —— createdBy / createdAt / updatedAt 由后端产出,
* 新建时压根不存在(对齐 BlankProblem / BlankContest 的写法)。
*
* `code` 收窄成 string:读回来时统一 `?? ""`,代码编辑器的 v-model 不接受 null。
* 新建时压根不存在(对齐 BlankProblem / BlankContest 的写法)。「示例代码」字段界面上
* 去掉了(正文里的代码块能直接运行),挂的题走单独的接口。
*/
export type TutorialEdit = Omit<AdminTutorial, "createdBy" | "createdAt" | "updatedAt" | "code"> & {
code: string
}
export type TutorialEdit = Omit<
AdminTutorial,
"createdBy" | "createdAt" | "updatedAt" | "code" | "problems"
>
export interface ExerciseMcqData {
question: string
@@ -448,6 +446,8 @@ export type { ExerciseType } from "@oj2/contract"
* 前端按题型收窄成判别联合 —— 组件靠它区分七种题型的字段。
*/
export type Exercise = Omit<AdminExercise, "data"> & {
/** 学生端才有:当前学生做对过没有(后台的练习列表不下发) */
solved?: boolean
data:
| ExerciseMcqData
| ExerciseSortData