refactor(目录): 目录名对上界面上的名字,自学的「一课」不再也叫 lesson
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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:59:43 +08:00
1 parent 29c31640b5
commit 48590eee60
37 files changed
+1238 -1236

No files matched your search

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api'
/**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/
const props = defineProps<{
/** null = 补一节 */
lesson: AdminLesson | null
}>()
const show = defineModel<boolean>('show', { required: true })
const emit = defineEmits<{ saved: [] }>()
const message = useMessage()
const configStore = useConfigStore()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const modeOptions = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
const className = ref<string | null>(null)
const day = ref<string | null>(null)
const time = ref<string | null>(null)
const language = ref<LessonLanguage>('Python')
/** 做什么:画流程图不用选语言(存成 null) */
const mode = ref<LessonMode>('code')
const needsCode = computed(() => lessonNeeds(mode.value).code)
const idsInput = ref('')
const saving = ref(false)
watch(show, (open) => {
if (!open) return
const lesson = props.lesson
const now = new Date()
className.value = lesson?.className ?? null
// 日子、时刻都按东八区:n-date-picker / n-time-picker 绑的是格式化后的文本,不经过浏览器时区
day.value = lesson?.day ?? parseTime(now, 'YYYY-MM-DD')
time.value = parseTime(lesson?.createdAt ?? now, 'HH:mm')
language.value = lesson?.language ?? 'Python'
mode.value = lesson?.mode ?? 'code'
idsInput.value = lesson?.problems.map((p) => p.problemDisplayId).join(' ') ?? ''
})
function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
}
// ---- 选题弹框,和任务页上那个是同一个 ----
const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() {
const titles = new Map(props.lesson?.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]) ?? [])
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
function picked(ids: string[]) {
idsInput.value = ids.join(' ')
picking.value = false
}
const ready = computed(() => !!className.value && !!day.value && !!time.value && inputIds().length > 0)
async function save() {
if (!ready.value) return
saving.value = true
const body = {
className: className.value!,
day: day.value!,
time: time.value!,
language: needsCode.value ? language.value : null,
mode: mode.value,
problemDisplayIds: inputIds(),
}
try {
if (props.lesson) await updateAdminLesson(props.lesson.id, body)
else await createAdminLesson(body)
message.success(props.lesson ? '改好了' : '补上了')
show.value = false
emit('saved')
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal v-model:show="show" preset="card" :title="lesson ? '改这一节' : '补一节'" :bordered="false" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
<n-form label-placement="left" label-width="64">
<n-form-item label="班级">
<n-select v-model:value="className" :options="classOptions" filterable placeholder="选班级" />
</n-form-item>
<n-form-item label="上课">
<div class="when">
<n-date-picker v-model:formatted-value="day" value-format="yyyy-MM-dd" type="date" class="day" />
<n-time-picker v-model:formatted-value="time" value-format="HH:mm" format="HH:mm" class="time" />
</div>
</n-form-item>
<n-form-item label="做什么">
<div class="when">
<n-select v-model:value="mode" :options="modeOptions" class="lang" />
<n-select v-if="needsCode" v-model:value="language" :options="languageOptions" aria-label="用什么语言写" class="lang" />
</div>
</n-form-item>
<n-form-item label="题目">
<div class="ids">
<n-input v-model:value="idsInput" placeholder="题号,用空格隔开" @keyup.enter="save" />
<n-button @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="foot">
<n-button @click="show = false">取消</n-button>
<n-button type="primary" :disabled="!ready" :loading="saving" @click="save">{{ lesson ? '保存' : '补上' }}</n-button>
</div>
</template>
<ProblemPicker
v-model:show="picking"
v-model:language="language"
v-model:lesson-mode="mode"
:initial="pickerInitial"
:saving="false"
mode="pick"
@confirm="picked"
/>
</n-modal>
</template>
<style scoped>
.when,
.ids {
display: flex;
gap: 8px;
width: 100%;
}
.day {
flex: 1 1 0;
}
.time {
width: 110px;
}
.lang {
width: 140px;
}
.ids .n-input {
flex: 1 1 0;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>