- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上 - 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/ - 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上 - 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue - lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials) - 旧地址不留跳转 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29c31640b5
commit
48590eee60
37 files changed
+1238
-1236
No files matched your search
+1
-1
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
detail: StuckProblemDetail
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||
/** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||
|
||||
/** 百分比,取整;分母是 0 时给 0 */
|
||||
export function rate(part: number, whole: number) {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -7,7 +7,7 @@ import type { LearnStudentDetail } from 'utils/types'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import { EXERCISE_KIND } from './kinds'
|
||||
|
||||
/**
|
||||
@@ -30,31 +30,31 @@ const theme = useThemeVars()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
useBackClose(show, () => !isDesktop.value)
|
||||
|
||||
type Lesson = LearnStudentDetail['lessons'][number]
|
||||
type Exercise = Lesson['exercises'][number]
|
||||
type Problem = Lesson['problems'][number]
|
||||
type Tutorial = LearnStudentDetail['tutorials'][number]
|
||||
type Exercise = Tutorial['exercises'][number]
|
||||
type Problem = Tutorial['problems'][number]
|
||||
|
||||
const detail = ref<LearnStudentDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const expanded = ref(new Set<number>())
|
||||
|
||||
const summary = computed(() => {
|
||||
const lessons = detail.value?.lessons ?? []
|
||||
const exercises = lessons.flatMap((lesson) => lesson.exercises)
|
||||
const tutorials = detail.value?.tutorials ?? []
|
||||
const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
|
||||
// 一道题挂在两课只算一次
|
||||
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p]))
|
||||
const problems = new Map(tutorials.flatMap((tutorial) => tutorial.problems).map((p) => [p.id, p]))
|
||||
return {
|
||||
problemSolved: [...problems.values()].filter((p) => p.solved).length,
|
||||
problemTotal: problems.size,
|
||||
opened: lessons.filter((lesson) => lesson.viewCount > 0).length,
|
||||
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0),
|
||||
opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
|
||||
seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
|
||||
solved: exercises.filter((ex) => ex.solved).length,
|
||||
total: exercises.length,
|
||||
}
|
||||
})
|
||||
|
||||
function solvedOf(lesson: Lesson) {
|
||||
return lesson.exercises.filter((ex) => ex.solved).length
|
||||
function solvedOf(tutorial: Tutorial) {
|
||||
return tutorial.exercises.filter((ex) => ex.solved).length
|
||||
}
|
||||
|
||||
function stateOf(ex: Exercise) {
|
||||
@@ -92,13 +92,13 @@ watch(
|
||||
const data = await getLearnStudent(userId, { type })
|
||||
detail.value = data
|
||||
expanded.value = new Set(
|
||||
data.lessons
|
||||
data.tutorials
|
||||
.filter(
|
||||
(lesson) =>
|
||||
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
|
||||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||
(tutorial) =>
|
||||
(tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
|
||||
tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||
)
|
||||
.map((lesson) => lesson.tutorialId),
|
||||
.map((tutorial) => tutorial.tutorialId),
|
||||
)
|
||||
} catch {
|
||||
detail.value = null
|
||||
@@ -133,40 +133,42 @@ watch(
|
||||
'--bad': theme.errorColor,
|
||||
}"
|
||||
>
|
||||
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div>
|
||||
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson">
|
||||
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)">
|
||||
<span class="lesson-line">
|
||||
<span class="lesson-title">
|
||||
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span>
|
||||
{{ splitTitle(lesson.title).kp }}
|
||||
<div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
|
||||
<div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
|
||||
<button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
|
||||
<span class="tutorial-line">
|
||||
<span class="tutorial-title">
|
||||
<span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
|
||||
{{ splitTitle(tutorial.title).kp }}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span>
|
||||
<span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
|
||||
<span class="squares">
|
||||
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||
<span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||
</span>
|
||||
<Icon
|
||||
v-if="lesson.exercises.length || lesson.problems.length"
|
||||
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||
v-if="tutorial.exercises.length || tutorial.problems.length"
|
||||
:icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||
:width="12"
|
||||
class="muted"
|
||||
/>
|
||||
</span>
|
||||
<span class="lesson-line small muted num">
|
||||
<template v-if="lesson.viewCount">
|
||||
打开 {{ lesson.viewCount }} 次
|
||||
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template>
|
||||
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
||||
<span class="tutorial-line small muted num">
|
||||
<template v-if="tutorial.viewCount">
|
||||
打开 {{ tutorial.viewCount }} 次
|
||||
<template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
|
||||
<template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
||||
</template>
|
||||
<template v-else>没打开过,直接做的练一练</template>
|
||||
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
|
||||
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template>
|
||||
<template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
|
||||
<template v-if="tutorial.problems.length">
|
||||
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list">
|
||||
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
|
||||
<div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
|
||||
<div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
|
||||
<span class="square" :class="stateOf(ex)" />
|
||||
<div class="ex-body">
|
||||
<div class="ex-question">
|
||||
@@ -183,8 +185,8 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div>
|
||||
<div v-for="problem in lesson.problems" :key="problem.id" class="ex">
|
||||
<div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
|
||||
<div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
|
||||
<span class="square" :class="problemStateOf(problem)" />
|
||||
<div class="ex-body">
|
||||
<div class="ex-question">
|
||||
@@ -235,13 +237,13 @@ watch(
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.lesson {
|
||||
.tutorial {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
.lesson:first-child {
|
||||
.tutorial:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.lesson-head {
|
||||
.tutorial-head {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
border: 0;
|
||||
@@ -254,13 +256,13 @@ watch(
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lesson-line {
|
||||
.tutorial-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.lesson-title {
|
||||
.tutorial-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import 'oj/learn/learn.css'
|
||||
import ExerciseAttempts from './ExerciseAttempts.vue'
|
||||
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
||||
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
|
||||
const className = ref<string | null>(null)
|
||||
const board = ref<LearnBoard | null>(null)
|
||||
const exercises = ref<LearnExerciseProgress[]>([])
|
||||
const lessonId = ref<number | null>(null)
|
||||
const selectedTutorialId = ref<number | null>(null)
|
||||
const expanded = ref<number | null>(null)
|
||||
const side = ref<'exercise' | 'problem'>('exercise')
|
||||
const problemOpen = ref<number | null>(null)
|
||||
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
||||
const tutorialProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
||||
/** 学生 → 题 → 交过的情况 */
|
||||
const problemStates = computed(
|
||||
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
|
||||
)
|
||||
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
|
||||
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step]))))
|
||||
const steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
|
||||
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||
const lessonOptions = computed(() =>
|
||||
const tutorialOptions = computed(() =>
|
||||
groups.value.flatMap((group) =>
|
||||
group.lessons.map((lesson) => ({
|
||||
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
|
||||
value: lesson.id,
|
||||
group.tutorials.map((tutorial) => ({
|
||||
label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
|
||||
value: tutorial.id,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value))
|
||||
const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
|
||||
/** 读过(或做过练一练)这一课的人 */
|
||||
function readersOf(id: number) {
|
||||
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id))
|
||||
return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
|
||||
}
|
||||
const sideProblems = computed(() => {
|
||||
const id = lessonId.value
|
||||
const id = selectedTutorialId.value
|
||||
if (id === null) return []
|
||||
const readers = readersOf(id)
|
||||
return (lessonProblems.value.get(id) ?? []).map((problem) => {
|
||||
return (tutorialProblems.value.get(id) ?? []).map((problem) => {
|
||||
const people: ProblemReader[] = readers.map((row) => ({
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
|
||||
})
|
||||
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
|
||||
|
||||
function lessonOf(row: LearnBoardStudent, id: number) {
|
||||
return row.lessons.find((lesson) => lesson.tutorialId === id)
|
||||
function tutorialOf(row: LearnBoardStudent, id: number) {
|
||||
return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
|
||||
}
|
||||
|
||||
function cellOf(row: LearnBoardStudent, id: number) {
|
||||
if (row.done.includes(id)) return 'done'
|
||||
const lesson = lessonOf(row, id)
|
||||
if (lesson) return lesson.solved ? 'partial' : 'partial stuck'
|
||||
const tutorial = tutorialOf(row, id)
|
||||
if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
|
||||
if (row.opened.includes(id)) return 'opened'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||
function cellStyle(row: LearnBoardStudent, id: number) {
|
||||
const lesson = lessonOf(row, id)
|
||||
const tutorial = tutorialOf(row, id)
|
||||
const total = exerciseTotal.value.get(id) ?? 0
|
||||
if (!lesson || !total || row.done.includes(id)) return undefined
|
||||
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` }
|
||||
if (!tutorial || !total || row.done.includes(id)) return undefined
|
||||
return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
|
||||
}
|
||||
|
||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
||||
const total = exerciseTotal.value.get(id) ?? 0
|
||||
const lesson = lessonOf(row, id)
|
||||
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ''
|
||||
const problems = lessonProblems.value.get(id) ?? []
|
||||
const tutorial = tutorialOf(row, id)
|
||||
const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
|
||||
const problems = tutorialProblems.value.get(id) ?? []
|
||||
const states = problemStates.value.get(row.userId)
|
||||
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
|
||||
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
|
||||
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
|
||||
if (tutorial) return `${head}:练一练 ${tutorial.solved}/${total}${wrong}${tail}`
|
||||
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
|
||||
return `${head}:没打开`
|
||||
}
|
||||
|
||||
/** 他打开过或做过练一练的那些课 */
|
||||
function touched(row: LearnBoardStudent) {
|
||||
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
|
||||
return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
|
||||
}
|
||||
|
||||
/** 应做 = 他碰过的那些课,练一练加起来几道 */
|
||||
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
|
||||
function problemCount(row: LearnBoardStudent) {
|
||||
const ids = new Set<number>()
|
||||
for (const id of touched(row)) {
|
||||
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id)
|
||||
for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
|
||||
}
|
||||
const states = problemStates.value.get(row.userId)
|
||||
let solved = 0
|
||||
@@ -180,7 +180,7 @@ async function load() {
|
||||
exercises.value = ex.results
|
||||
// 练一练默认看大家打开得最多的那课
|
||||
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
||||
selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -235,11 +235,11 @@ onMounted(load)
|
||||
<div class="line head">
|
||||
<span class="c-name strong">学到哪了</span>
|
||||
<div class="cells-head">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
|
||||
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
|
||||
<span class="group-steps">
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
|
||||
{{ lesson.step }}
|
||||
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
|
||||
{{ tutorial.step }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -254,14 +254,14 @@ onMounted(load)
|
||||
<UserName :username="row.username" />
|
||||
</button>
|
||||
<div class="cells">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||
<span
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
v-for="tutorial in group.tutorials"
|
||||
:key="tutorial.id"
|
||||
class="cell"
|
||||
:class="cellOf(row, lesson.id)"
|
||||
:style="cellStyle(row, lesson.id)"
|
||||
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
||||
:class="cellOf(row, tutorial.id)"
|
||||
:style="cellStyle(row, tutorial.id)"
|
||||
:title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,14 +284,14 @@ onMounted(load)
|
||||
<div v-else class="line foot">
|
||||
<span class="c-name muted small">每课打开过</span>
|
||||
<div class="cells bars">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
|
||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
|
||||
<span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
|
||||
<span
|
||||
class="bar"
|
||||
:class="{ zero: !openedCount.get(lesson.id) }"
|
||||
:class="{ zero: !openedCount.get(tutorial.id) }"
|
||||
:style="{
|
||||
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`,
|
||||
height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
@@ -316,7 +316,7 @@ onMounted(load)
|
||||
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
||||
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
||||
</div>
|
||||
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" />
|
||||
<n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
|
||||
</div>
|
||||
<template v-if="side === 'problem'">
|
||||
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
||||
@@ -333,8 +333,8 @@ onMounted(load)
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
||||
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex">
|
||||
<div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
|
||||
<div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
|
||||
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
||||
<span class="ex-line">
|
||||
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||
@@ -711,7 +711,7 @@ onMounted(load)
|
||||
.class-select {
|
||||
width: 170px;
|
||||
}
|
||||
.lesson-select {
|
||||
.tutorial-select {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
+5
-5
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
|
||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||
import PreviewStrip from './components/PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
||||
import PreviewStrip from './PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
|
||||
import type { AdminProblem } from 'utils/types'
|
||||
import { getProblem } from '../api'
|
||||
import ProblemDetail from './detail.vue'
|
||||
import SqlDetail from './SqlDetail.vue'
|
||||
import SqlDetail from './components/SqlDetail.vue'
|
||||
|
||||
/**
|
||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
||||
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 { parseTutorial } from 'oj/learn/composables/useTutorialParse'
|
||||
import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
|
||||
import TutorialBody from 'oj/learn/components/TutorialBody.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
||||
import ExerciseManager from './components/ExerciseManager.vue'
|
||||
@@ -69,7 +69,7 @@ const heading = computed(() => {
|
||||
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
||||
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
||||
})
|
||||
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
|
||||
const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
|
||||
|
||||
async function init() {
|
||||
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
||||
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
|
||||
}
|
||||
|
||||
function openStudentView() {
|
||||
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank')
|
||||
if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
|
||||
}
|
||||
|
||||
watch(() => props.tutorialID, init)
|
||||
@@ -199,7 +199,7 @@ onMounted(init)
|
||||
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
||||
</div>
|
||||
<div class="preview">
|
||||
<LessonBody
|
||||
<TutorialBody
|
||||
:segments="preview.segments"
|
||||
:lang="tutorial.type"
|
||||
:tutorial-id="tutorial.id"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { TutorialListItem } from 'utils/types'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
||||
@@ -28,11 +28,11 @@ 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)!)
|
||||
const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
|
||||
return {
|
||||
...group,
|
||||
items,
|
||||
key: `${group.project}:${group.lessons[0]!.id}`,
|
||||
key: `${group.project}:${group.tutorials[0]!.id}`,
|
||||
hidden: items.every((item) => !item.isPublic),
|
||||
}
|
||||
})
|
||||
@@ -189,7 +189,7 @@ onMounted(load)
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="row lesson"
|
||||
class="row tutorial"
|
||||
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
||||
draggable="true"
|
||||
@dragstart="dragId = item.id"
|
||||
@@ -330,10 +330,10 @@ onMounted(load)
|
||||
.group-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.lesson.off {
|
||||
.tutorial.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lesson.dragging {
|
||||
.tutorial.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.handle {
|
||||
|
||||
Reference in new issue
Block a user