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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:40:34 +08:00

167 lines
4.7 KiB
Vue

<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
import { reportExerciseAttempt } from "oj/api"
import { useUserStore } from "shared/store/user"
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
const ExerciseMatch = defineAsyncComponent(() => import("./ExerciseMatch.vue"))
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
/** 这一课的第几道、一共几道 */
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,留痕只在这里做一次 ——
* 每个题型组件里各写一遍上报,早晚会漏掉一两个。
*
* 两道闸:做对之后不再上报(后端也冻结,这里省一次请求);同一份答案连点两次不算
* 两次(排序题、连线题的「提交」按钮点完不会禁用,一个字没动再点一下不是新的尝试)。
*/
let solved = false
let lastAnswer: string | null = null
// 教程页里 v-for 的 key 是段落序号,换课时组件实例会被复用 —— 不跟着题目 id 重置的话,
// 上一课做对的状态会把这一课的第一次作答吃掉
watch(
() => props.exercise.id,
() => {
solved = false
lastAnswer = null
},
)
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
lastAnswer = answer
if (payload.correct) solved = true
reportExerciseAttempt(props.exercise.id, payload)
}
</script>
<template>
<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>