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>
517 lines
15 KiB
Vue
517 lines
15 KiB
Vue
<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 AdminLink from 'shared/components/AdminLink.vue'
|
||
import { useUserStore } from 'shared/store/user'
|
||
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
|
||
import TutorialRow from './components/TutorialRow.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) => tutorialState(course.progress[id])
|
||
}
|
||
|
||
function firstTutorial(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) => tutorialState(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(tutorialPath(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 && firstTutorial(course)" class="first">
|
||
<span class="muted">第 1 课</span>
|
||
<span class="first-kp">{{ firstTutorial(course)!.kp }}</span>
|
||
<span class="sec">{{ firstTutorial(course)!.scene }}</span>
|
||
</div>
|
||
<SegBar :groups="groupByProject(course.titles)" :state-of="stateOf(course)" :width="resume ? 12 : 14" />
|
||
<router-link v-if="resume" :to="tutorialPath(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(tutorialPath(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 v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
|
||
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
|
||
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="current && isDesktop" class="map">
|
||
<section v-for="group in groups" :key="group.tutorials[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.tutorials.map((l) => l.id),
|
||
) === group.tutorials.length
|
||
"
|
||
class="project-count all"
|
||
>
|
||
<Icon icon="ph:check-bold" :width="13" />{{ group.tutorials.length }}/{{ group.tutorials.length }}
|
||
</span>
|
||
<span
|
||
v-else-if="
|
||
userStore.isAuthed &&
|
||
doneIn(
|
||
current,
|
||
group.tutorials.map((l) => l.id),
|
||
)
|
||
"
|
||
class="project-count"
|
||
>
|
||
{{
|
||
doneIn(
|
||
current,
|
||
group.tutorials.map((l) => l.id),
|
||
)
|
||
}}/{{ group.tutorials.length }}
|
||
</span>
|
||
<span v-else class="project-count">{{ group.tutorials.length }} 课</span>
|
||
</div>
|
||
<TutorialRow
|
||
v-for="item in group.tutorials"
|
||
:key="item.id"
|
||
:title="item.title"
|
||
:step="item.step"
|
||
:to="tutorialPath(current.type, item.step)"
|
||
:progress="current.progress[item.id]"
|
||
:active="resume?.course.type === current.type && resume.step === item.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;
|
||
}
|
||
.admin-links {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.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>
|