Files
OJ2/apps/web/src/oj/learn/components/TutorialEnd.vue
T
xuyueandClaude Opus 5.5 48590eee60
Deploy / deploy (push) Canceled after 0s
refactor(目录): 目录名对上界面上的名字,自学的「一课」不再也叫 lesson
- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上
- 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/
- 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上
- 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue
- lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials)
- 旧地址不留跳转

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:59:43 +08:00

280 lines
6.8 KiB
Vue

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { TutorialProblem } from '@oj2/contract'
import { splitTitle, type TutorialState, type ProjectGroup } from '../utils'
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) => TutorialState
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>