Deploy / deploy (push) Canceled after 0s
- 后台课堂任务 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>
173 lines
4.4 KiB
Vue
173 lines
4.4 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue'
|
|
import type { TutorialProgress } from '@oj2/contract'
|
|
import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
|
|
import TutorialRow from './TutorialRow.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.tutorials[0]!.id}`
|
|
}
|
|
|
|
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
|
watch(
|
|
() => [props.groups, props.currentStep] as const,
|
|
([groups, step]) => {
|
|
const group = groups.find((item) => item.tutorials.some((tutorial) => tutorial.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.tutorials.filter((tutorial) => tutorialState(props.progress[tutorial.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.tutorials.length" class="count all">
|
|
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
|
|
</span>
|
|
<span v-else class="count">{{ doneIn(group) }}/{{ group.tutorials.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="tutorials">
|
|
<TutorialRow
|
|
v-for="tutorial in group.tutorials"
|
|
:key="tutorial.id"
|
|
:title="tutorial.title"
|
|
:step="tutorial.step"
|
|
:to="tutorialPath(type, tutorial.step)"
|
|
:progress="progress[tutorial.id]"
|
|
:active="tutorial.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;
|
|
}
|
|
.tutorials {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding-left: 6px;
|
|
}
|
|
.big .tutorials {
|
|
padding: 0 0 8px;
|
|
}
|
|
</style>
|