- 顶栏「自学」进新的自学首页 /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
+1
-1
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user