- 顶栏「自学」进新的自学首页 /learn:接着学(按服务端记录,不再串到上一个学生)+ 按项目分组的课程地图 - 读一课:左边目录按项目折叠,正文加宽,右边本课大纲带练一练标记;去掉了不能运行的示例代码栏 - 正文里的代码块能改能运行,报错用题目页那套中文解释;读输入的代码出输入框 - 课末:练一练做到几道 → 学完了 → 老师挑的几道题(必会标出来)→ 下一课 - 练一练统一成绿色提交、结果一行字;手机版目录从底部弹出 - 后台:教程列表按项目分组拖动排序,编辑页左写右看、练一练插到光标处、挑相关题; 自学情况改成方块格 + 还没打开过的名单 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11194543a3
commit
845e41c060
43 files changed
+4675
-1294
No files matched your search
+30
-18
@@ -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 }) {
|
||||
|
||||
+557
-437
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user