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

+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>