refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改 - 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转 - 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today), 首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts - 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46d3fe2dbe
commit
29c31640b5
82 files changed
+798
-766
No files matched your search
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode, type Tag } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getProblemList, getProblemTagList } from 'shared/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { ProblemRow } from 'utils/types'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
|
||||
/**
|
||||
* 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
|
||||
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
|
||||
* 点「布置」直接存,和在输入框里敲题号回车是一回事
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 打开时已经在框里的题(题号 + 知道的话带上题名) */
|
||||
initial: { id: string; title?: string }[]
|
||||
saving: boolean
|
||||
/** pick = 只把选好的题交回去(后台改一节时用),不直接布置 */
|
||||
mode?: 'assign' | 'pick'
|
||||
}>()
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const language = defineModel<LessonLanguage>('language', { required: true })
|
||||
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
|
||||
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
|
||||
const needs = computed(() => lessonNeeds(lessonMode.value))
|
||||
/** 只画流程图不看语言 */
|
||||
const languageFilter = computed(() => (needs.value.code ? language.value : ''))
|
||||
const emit = defineEmits<{ confirm: [ids: string[]] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||
const LESSON_MODE_OPTIONS = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
|
||||
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
|
||||
const SORTS = [
|
||||
{ label: '最新出的', value: '' },
|
||||
{ label: '做过的人最多', value: '-submission_number' },
|
||||
]
|
||||
const PAGE_SIZE = 100
|
||||
/** 老师挑的题,必会题是按课堂用得最多挑出来的,放在知识点前面 */
|
||||
const MUST_DO = '必会题'
|
||||
|
||||
const tags = ref<Tag[]>([])
|
||||
const tag = ref('')
|
||||
const keyword = ref('')
|
||||
const sort = ref('')
|
||||
const page = ref(1)
|
||||
const rows = ref<ProblemRow[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 勾的先后就是这节课的顺序 */
|
||||
const picked = ref<{ id: string; title?: string }[]>([])
|
||||
const pickedIndex = computed(() => new Map(picked.value.map((p, i) => [p.id.toLowerCase(), i])))
|
||||
|
||||
const knowledge = computed(() => tags.value.filter((t) => t.category === 'knowledge').sort((a, b) => b.problemCount - a.problemCount))
|
||||
const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null)
|
||||
const allCount = ref(0)
|
||||
|
||||
async function loadTags() {
|
||||
const list = await getProblemTagList(languageFilter.value, needs.value.draw)
|
||||
tags.value = list
|
||||
// 换了语言,原来选的知识点在这个语言下可能一道都没有
|
||||
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
async function loadRows() {
|
||||
const mine = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProblemList((page.value - 1) * PAGE_SIZE, PAGE_SIZE, {
|
||||
tag: tag.value,
|
||||
keyword: keyword.value.trim(),
|
||||
sort: sort.value,
|
||||
language: languageFilter.value,
|
||||
...(needs.value.draw ? { flowchart: '1' } : {}),
|
||||
})
|
||||
if (mine !== seq) return
|
||||
rows.value = res.results
|
||||
total.value = res.total
|
||||
if (!tag.value && !keyword.value.trim()) allCount.value = res.total
|
||||
} finally {
|
||||
if (mine === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
function reload() {
|
||||
page.value = 1
|
||||
listRef.value?.scrollTo({ top: 0 })
|
||||
loadRows()
|
||||
}
|
||||
|
||||
watch(show, (open) => {
|
||||
if (!open) return
|
||||
picked.value = props.initial.map((p) => ({ ...p }))
|
||||
keyword.value = ''
|
||||
loadTags()
|
||||
reload()
|
||||
})
|
||||
watch([languageFilter, () => needs.value.draw], () => {
|
||||
if (!show.value) return
|
||||
loadTags()
|
||||
reload()
|
||||
})
|
||||
// 搜题号多半是记得那道题、不知道它在哪个知识点下,所以搜的是整个题库;点了知识点就不再搜
|
||||
function pickTag(name: string) {
|
||||
keyword.value = ''
|
||||
tag.value = name
|
||||
}
|
||||
watch(keyword, (text) => {
|
||||
if (text.trim()) tag.value = ''
|
||||
})
|
||||
watch([tag, sort], reload)
|
||||
watchDebounced(keyword, reload, { debounce: 300 })
|
||||
watch(page, () => {
|
||||
listRef.value?.scrollTo({ top: 0 })
|
||||
loadRows()
|
||||
})
|
||||
|
||||
function toggle(row: ProblemRow) {
|
||||
const key = row._id.toLowerCase()
|
||||
if (pickedIndex.value.has(key)) picked.value = picked.value.filter((p) => p.id.toLowerCase() !== key)
|
||||
else picked.value = [...picked.value, { id: row._id, title: row.title }]
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
picked.value = picked.value.filter((p) => p.id !== id)
|
||||
}
|
||||
|
||||
const emptyText = computed(() => {
|
||||
if (!needs.value.code) return '没有能画流程图的题'
|
||||
const text = `没有能用 ${LANGUAGE_SHOW_VALUE[language.value]} 做的题`
|
||||
return needs.value.draw ? `${text}(还要能画流程图)` : text
|
||||
})
|
||||
|
||||
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
|
||||
function difficultyStyle(row: ProblemRow) {
|
||||
if (!row.difficulty) return {}
|
||||
const t = tone(DIFFICULTY_TONE[row.difficulty])
|
||||
return { color: t.color, background: t.background }
|
||||
}
|
||||
|
||||
// n-modal 把内容挂到 body 下,<style> 里的 v-bind() 传不进去,颜色走根节点的内联变量
|
||||
const vars = computed(() => ({
|
||||
// 宽度也写在这里:n-modal 的 preset card 上 scoped 的 class 宽度不生效
|
||||
width: 'min(1040px, calc(100vw - 32px))',
|
||||
'--text2': theme.value.textColor2,
|
||||
'--text3': theme.value.textColor3,
|
||||
'--divider': theme.value.dividerColor,
|
||||
'--hover': theme.value.hoverColor,
|
||||
'--action': theme.value.actionColor,
|
||||
'--primary': theme.value.primaryColor,
|
||||
'--on-bg': tone('success').background,
|
||||
'--on-text': tone('success').color,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
|
||||
<template #header-extra>
|
||||
<div class="head-extra">
|
||||
<n-select v-model:value="lessonMode" :options="LESSON_MODE_OPTIONS" aria-label="这节课做什么" size="small" class="mode" />
|
||||
<template v-if="needs.code">
|
||||
<span class="muted">用</span>
|
||||
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言写" size="small" class="lang" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<nav class="tags">
|
||||
<button class="tag" :class="{ on: tag === '' && !keyword.trim() }" @click="pickTag('')">
|
||||
<span>全部</span><span class="count">{{ allCount || '' }}</span>
|
||||
</button>
|
||||
<button v-if="mustDo" class="tag" :class="{ on: tag === MUST_DO }" @click="pickTag(MUST_DO)">
|
||||
<span>{{ MUST_DO }}</span
|
||||
><span class="count">{{ mustDo.problemCount }}</span>
|
||||
</button>
|
||||
<div class="tag-head">知识点</div>
|
||||
<button v-for="t in knowledge" :key="t.id" class="tag" :class="{ on: tag === t.name }" @click="pickTag(t.name)">
|
||||
<span>{{ t.name }}</span
|
||||
><span class="count">{{ t.problemCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="main">
|
||||
<div class="filters">
|
||||
<n-input v-model:value="keyword" placeholder="搜题号或题目名" clearable class="search">
|
||||
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="sort" :options="SORTS" class="sort" />
|
||||
</div>
|
||||
|
||||
<div ref="listRef" class="list">
|
||||
<n-spin :show="loading" class="spin">
|
||||
<n-empty v-if="!loading && !rows.length" :description="emptyText" class="empty" />
|
||||
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
|
||||
<span class="check">
|
||||
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
|
||||
</span>
|
||||
<span class="pid">{{ row._id }}</span>
|
||||
<span class="title">
|
||||
<span class="title-text">{{ row.title }}</span>
|
||||
<ProblemTypeTag v-if="row.allowFlowchart" kind="flowchart" />
|
||||
<ProblemTypeTag v-if="row.hasAstRules" kind="ast" />
|
||||
</span>
|
||||
<span class="diff" :style="difficultyStyle(row)">{{ row.difficulty ?? '' }}</span>
|
||||
<span class="people">{{ row.solvedUsers }}/{{ row.triedUsers }}</span>
|
||||
</button>
|
||||
</n-spin>
|
||||
</div>
|
||||
<div v-if="total > PAGE_SIZE" class="pager">
|
||||
<n-pagination v-model:page="page" :page-size="PAGE_SIZE" :item-count="total" simple />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="foot">
|
||||
<div class="picked">
|
||||
<span v-if="!picked.length" class="muted">还没选题</span>
|
||||
<span v-for="(p, i) in picked" :key="p.id" class="pick">
|
||||
<b>{{ i + 1 }}</b>
|
||||
<span>{{ p.id }}</span>
|
||||
<span v-if="p.title" class="pick-title">{{ p.title }}</span>
|
||||
<button type="button" class="x" :aria-label="`去掉 ${p.id}`" @click="remove(p.id)"><Icon icon="ph:x-bold" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!picked.length && (mode === 'pick' || !initial.length)"
|
||||
:loading="saving"
|
||||
@click="
|
||||
emit(
|
||||
'confirm',
|
||||
picked.map((p) => p.id),
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="mode === 'pick'">{{ picked.length ? `用这 ${picked.length} 道` : '选好了' }}</template>
|
||||
<template v-else>{{ picked.length ? `布置这 ${picked.length} 道` : initial.length ? '清掉' : '布置' }}</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lang {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: min(560px, calc(100vh - 260px));
|
||||
}
|
||||
|
||||
/* ---- 左栏:知识点 ---- */
|
||||
/* 全部 + 必会题 + 十几个知识点要整列放下、不出滚动条:36px 一行时差 40px,「总是显示滚动条」
|
||||
的电脑上冒出一根。32px 一行在 1366×768(框高 508px)上是 493px */
|
||||
.tags {
|
||||
flex: 0 0 168px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.tag-head {
|
||||
margin: 8px 10px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.tag.on {
|
||||
background: var(--on-bg);
|
||||
color: var(--on-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tag.on .count {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- 右边:题 ---- */
|
||||
.main {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spin {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: var(--on-bg);
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
border: 1.5px solid var(--text3);
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row.on .check {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pid {
|
||||
flex: 0 0 56px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.people {
|
||||
flex: 0 0 76px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- 底下:选好的,按先后 ---- */
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picked {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--action);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.pick b {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.pick-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.x:hover {
|
||||
background: var(--hover);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.tags {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 8px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.tag-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.people {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.foot {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.picked {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user