You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/teacherEnd/trainingTask/index.vue

1691 lines
56 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="training-task-page">
<!-- 筛选工具栏 -->
<div class="task-toolbar">
<div class="toolbar-left">
<div class="class-filter">
<span class="filter-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
教学班
</span>
<el-select v-model="selectedTeachingClassId" :loading="classesLoading" placeholder="请选择教学班"
class="class-select" @change="queryTasks">
<el-option v-for="schoolClass in teachingClassList" :key="schoolClass.schoolClassId"
:label="schoolClass.className" :value="schoolClass.schoolClassId" />
</el-select>
</div>
<div class="search-box">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<el-input v-model.trim="keyword" clearable placeholder="搜索任务名称..." @keyup.enter="queryTasks" />
<el-button type="primary" class="search-btn" @click="queryTasks">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
查询
</el-button>
</div>
</div>
<div class="toolbar-right">
<div class="status-indicator">
<el-tag v-if="selectedTeachingClass" type="success" effect="dark" size="small" class="status-tag">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
班级配置模式
</el-tag>
<el-tag v-else effect="plain" size="small" class="status-tag">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
</svg>
默认任务
</el-tag>
</div>
<el-button v-if="selectedTeachingClass" type="danger" plain class="toolbar-btn toolbar-btn-danger"
:loading="restoring" @click="restoreAllTasksDefaults">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
恢复默认
</el-button>
<el-button v-if="selectedTeachingClass" type="success" plain class="toolbar-btn toolbar-btn-success"
@click="openStudentDemo">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
学生端演示
</el-button>
</div>
</div>
<!-- 项目筛选标签 -->
<div class="project-filter">
<span class="filter-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
所属项目
</span>
<div class="project-tabs">
<button type="button" :class="['project-tab', { active: !activeProject }]" @click="toggleProject('')">
<span class="tab-name">全部</span>
<span class="tab-count">{{ tasks.length }}</span>
</button>
<button v-for="project in projectOptions" :key="project.name" type="button"
:class="['project-tab', { active: activeProject === project.name }]"
@click="toggleProject(project.name)">
<span class="tab-name">{{ project.name }}</span>
<span class="tab-count">{{ project.count }}</span>
</button>
</div>
<div class="filter-result">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12" />
</svg>
<span>显示 {{ filteredTasks.length }} / {{ tasks.length }} 条</span>
</div>
</div>
<!-- 任务卡片网格 -->
<section v-loading="loading" class="task-grid">
<article v-for="(task, index) in filteredTasks" :key="task.id || task.taskKey" class="task-card"
role="button" tabindex="0" @click="viewTask(task)" @keydown.enter.prevent="viewTask(task)">
<div class="card-decoration">
<svg class="corner-decoration top-left" width="24" height="24" viewBox="0 0 24 24">
<path d="M2 8V2h6" stroke="#00aaff" stroke-width="2" fill="none" opacity="0.6" />
</svg>
<svg class="corner-decoration top-right" width="24" height="24" viewBox="0 0 24 24">
<path d="M22 8V2h-6" stroke="#00aaff" stroke-width="2" fill="none" opacity="0.6" />
</svg>
<svg class="corner-decoration bottom-left" width="24" height="24" viewBox="0 0 24 24">
<path d="M2 16v6h6" stroke="#00ff9d" stroke-width="2" fill="none" opacity="0.4" />
</svg>
<svg class="corner-decoration bottom-right" width="24" height="24" viewBox="0 0 24 24">
<path d="M22 16v6h-6" stroke="#00ff9d" stroke-width="2" fill="none" opacity="0.4" />
</svg>
</div>
<div class="card-index">{{ String(index + 1).padStart(2, "0") }}</div>
<div class="card-content">
<div class="card-header">
<span class="task-project">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
{{ task.project || "默认实训任务" }}
</span>
<span :class="['task-status', { enabled: task.enabled }]">
{{ task.enabled ? "启用" : "停用" }}
</span>
</div>
<h3 class="task-name">{{ task.title }}</h3>
<div class="task-meta">
<span class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
{{ task.objectives?.length || 0 }} 个目标
</span>
<span class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{{ task.steps?.length || 0 }} 个步骤
</span>
</div>
</div>
<div class="card-actions" @click.stop>
<el-button type="primary" class="edit-btn" @click="handleTaskAction(task)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
{{ selectedTeachingClass ? "编辑任务" : "查看详情" }}
</el-button>
</div>
<div class="card-glow"></div>
<div class="scanline"></div>
</article>
<el-empty v-if="!filteredTasks.length && !loading" class="task-empty" description="暂无匹配的实训任务">
<template #image>
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="#00aaff" stroke-width="1"
opacity="0.3">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
</template>
</el-empty>
</section>
<!-- 编辑抽屉 -->
<el-drawer v-model="dialogVisible" :size="860" class="training-task-drawer" append-to-body destroy-on-close
:with-header="false">
<div class="drawer-shell">
<header class="drawer-header">
<div class="drawer-heading">
<span class="drawer-kicker">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4l3 3" />
</svg>
{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}
</span>
<h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3>
<p>维护教学班实训任务配置,包括任务名称、说明、目标、要求和步骤等内容</p>
</div>
<button class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
<div class="drawer-identity">
<div class="identity-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
<div class="identity-content">
<span class="identity-label">所属项目</span>
<strong>{{ taskForm.project || "未填写" }}</strong>
</div>
</div>
<div class="identity-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
<div class="identity-content">
<span class="identity-label">实训任务</span>
<strong>{{ taskForm.title || "未填写" }}</strong>
</div>
</div>
<div class="identity-item status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div class="identity-content">
<span class="identity-label">任务状态</span>
<strong :class="['status-value', { enabled: taskForm.enabled }]">
{{ taskForm.enabled ? "启用" : "停用" }}
</strong>
</div>
</div>
</div>
<el-form class="task-form" label-position="top" :disabled="isReadonly">
<section class="drawer-section">
<div class="section-title">
<span class="section-number">01</span>
<div class="section-info">
<h4>基础说明</h4>
<p>设置任务的基本信息</p>
</div>
</div>
<el-form-item label="实训任务名称:">
<el-input v-model="taskForm.title" placeholder="保存后会同步为学生端左侧导航名称" />
</el-form-item>
<el-form-item label="实训背景:">
<el-input v-model="taskForm.background" type="textarea" :rows="4" placeholder="请输入实训背景" />
</el-form-item>
</section>
<section class="drawer-section">
<div class="section-title">
<span class="section-number">02</span>
<div class="section-info">
<h4>目标与要求</h4>
<p>定义实训的学习目标</p>
</div>
</div>
<el-form-item label="实训目标:">
<div class="objective-list">
<div v-for="(objective, index) in taskForm.objectives" :key="index"
class="objective-row">
<div class="objective-number">{{ index + 1 }}</div>
<el-input v-model="taskForm.objectives[index]" placeholder="请输入实训目标" />
<button v-if="!isReadonly" type="button" class="remove-btn"
:disabled="taskForm.objectives.length <= 1" @click="removeObjective(index)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<button v-if="!isReadonly" type="button" class="add-objective-button"
@click="addObjective">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
新增目标
</button>
</div>
</el-form-item>
<el-form-item label="实训要求:">
<el-input v-model="taskForm.requirements" type="textarea" :rows="4" placeholder="请输入实训要求" />
</el-form-item>
</section>
<section class="drawer-section">
<div class="section-title">
<span class="section-number">03</span>
<div class="section-info">
<h4>资料与步骤</h4>
<p>上传案例资料、案例视频、实训指导手册与AI排错指导手册;步骤数量固定,仅可修改步骤名称</p>
</div>
</div>
<div v-for="slot in materialSlots" :key="slot.key" class="material-row">
<div class="material-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<div class="material-text">
<span class="material-label">{{ slot.label }}</span>
<span v-if="hasMaterial(slot.key)" class="material-link">{{
taskForm[`${slot.key}Name`] }}</span>
<span v-else class="material-placeholder">暂未上传</span>
</div>
</div>
<div class="material-ops">
<el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false"
:on-change="(file) => handleMaterialChange(slot.key, file)">
<el-button type="primary" :loading="uploadingField === slot.key"
class="upload-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{{ uploadingField === slot.key ? "上传中..." : "上传" }}
</el-button>
</el-upload>
<el-button v-if="!isReadonly && hasMaterial(slot.key)" type="danger" plain
class="clear-material-btn" @click="clearMaterial(slot.key)">清除</el-button>
</div>
</div>
<!-- 第一步选择题:编辑入口固定在第一个步骤行旁,仅三个白名单任务显示 -->
<StepOneQuestionBankPicker v-model:visible="bankPickerVisible" :load-questions="loadBankQuestions" :existing="taskForm.stepOneQuestions" @confirm="handleBankConfirm" />
<StepOneChoiceQuestionEditor v-model:visible="choiceEditorVisible" v-model="taskForm.stepOneQuestions" :task-name="taskForm.title" :load-questions="loadBankQuestions" />
<!-- 步骤数量与顺序固定,仅允许修改名称 -->
<el-form-item label="步骤名称:">
<div class="step-list">
<div v-for="(step, index) in taskForm.steps" :key="step.id" class="step-item">
<div class="step-connector" v-if="index > 0">
<svg width="20" height="12" viewBox="0 0 20 12">
<path d="M0 6h16M12 1l4 5-4 5" stroke="#00aaff" stroke-width="1.5"
fill="none" opacity="0.5" />
</svg>
</div>
<div class="step-content">
<span class="step-number">{{ index + 1 }}</span>
<el-input v-model="step.name" placeholder="步骤名称" />
<template v-if="index === 0 && supportsStepOneChoice(taskForm.taskKey)">
<el-button v-if="!isReadonly" type="primary" class="step-question-btn"
@click="choiceEditorVisible = true">
编辑题目{{ stepOneQuestionCount ? `(${stepOneQuestionCount}题)` : "" }}
</el-button>
<span v-else class="step-question-tag">已配置 {{ stepOneQuestionCount }} 道题</span>
</template>
</div>
</div>
</div>
</el-form-item>
</section>
</el-form>
<footer class="drawer-footer">
<el-button class="cancel-btn" @click="dialogVisible = false">
{{ isReadonly ? "关闭" : "取消" }}
</el-button>
<el-button v-if="!isReadonly" type="danger" plain class="toolbar-btn toolbar-btn-danger"
:loading="restoring" @click="restoreTaskDefault">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
恢复默认
</el-button>
<el-button v-if="!isReadonly" type="primary" class="save-btn" :loading="saving" @click="submitTask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
保存配置
</el-button>
</footer>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { listTrainingTasks, listClassTrainingTasks, saveClassTrainingTask, restoreClassTrainingTaskDefault, restoreClassTrainingTasksDefaults, uploadTrainingTaskMaterial } from "@/api/trainingTask"
import { createStudentDemoSession } from "@/api/studentDemo"
import * as indexApi from "@/api/teacher"
import useUserStore from "@/store/modules/user"
import StepOneChoiceQuestionEditor from "@/views/components/StepOneChoiceQuestionEditor.vue"
import StepOneQuestionBankPicker from "@/views/components/StepOneQuestionBankPicker.vue"
import { parseStepOneChoiceQuestions, supportsStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"
import { getQuestions } from "@/api/exam"
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const activeProject = ref("")
const keyword = ref("")
const loading = ref(false)
const classesLoading = ref(false)
const saving = ref(false)
const restoring = ref(false)
const uploadingField = ref("")
const choiceEditorVisible = ref(false)
const bankPickerVisible = ref(false)
// 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段
const materialSlots = [
{ key: "material", label: "案例资料" },
{ key: "trainingData", label: "案例视频" },
{ key: "guideManual", label: "实训指导手册" },
{ key: "aiManual", label: "AI排错指导手册" },
]
const tasks = ref([])
const classList = ref([])
const selectedTeachingClassId = ref("")
const dialogVisible = ref(false)
const dialogMode = ref("view")
const taskForm = reactive(createEmptyTask())
const currentUserId = computed(() => userStore.userInfo.userId)
const isReadonly = computed(() => dialogMode.value === "view")
const stepOneQuestionCount = computed(() => taskForm.stepOneQuestions?.length || 0)
const projects = computed(() => [...new Set(tasks.value.map((task) => task.project).filter(Boolean))])
const projectOptions = computed(() => {
return projects.value.map((project) => ({
name: project,
count: tasks.value.filter((task) => task.project === project).length,
}))
})
const teachingClassList = computed(() => {
return classList.value.filter((schoolClass) => {
return normalizeClassType(schoolClass) === "TEACHING" && String(schoolClass.createdBy || "") === String(currentUserId.value || "")
})
})
const selectedTeachingClass = computed(() => {
return teachingClassList.value.find((schoolClass) => schoolClass.schoolClassId === selectedTeachingClassId.value)
})
const filteredTasks = computed(() => {
const text = keyword.value.trim()
return tasks.value.filter((task) => {
const matchProject = !activeProject.value || task.project === activeProject.value
const matchKeyword = !text || task.title.includes(text)
return matchProject && matchKeyword
})
})
onMounted(() => {
setTrainingTaskMainTheme(true)
loadTeachingClasses()
})
onActivated(() => {
setTrainingTaskMainTheme(true)
})
onDeactivated(() => {
setTrainingTaskMainTheme(false)
})
onBeforeUnmount(() => {
setTrainingTaskMainTheme(false)
})
function setTrainingTaskMainTheme(enabled) {
const main = document.querySelector(".teacher-main")
if (!main) return
main.classList.toggle("training-task-main-theme", enabled)
}
function toggleProject(project) {
activeProject.value = activeProject.value === project ? "" : project
}
async function queryTasks() {
if (!selectedTeachingClassId.value) {
await queryDefaultTasks()
return
}
loading.value = true
try {
const res = await listClassTrainingTasks(selectedTeachingClassId.value, { enabledOnly: false })
tasks.value = (res.data || []).map(normalizeTask)
resetInvalidProject()
} catch (error) {
await queryDefaultTasks()
proxy?.$modal?.msgWarning?.("班级任务加载失败,已显示系统默认任务")
} finally {
loading.value = false
}
}
async function queryDefaultTasks() {
loading.value = true
try {
const res = await listTrainingTasks({ enabledOnly: false })
tasks.value = (res.data || []).map(normalizeTask)
resetInvalidProject()
} catch (error) {
tasks.value = []
} finally {
loading.value = false
}
}
function resetInvalidProject() {
if (activeProject.value && !projects.value.includes(activeProject.value)) {
activeProject.value = ""
}
}
async function loadTeachingClasses() {
classesLoading.value = true
try {
const res = await indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId })
classList.value = res.data || []
selectedTeachingClassId.value = teachingClassList.value[0]?.schoolClassId || ""
await queryTasks()
} catch (error) {
classList.value = []
selectedTeachingClassId.value = ""
await queryDefaultTasks()
} finally {
classesLoading.value = false
}
}
function normalizeClassType(schoolClass) {
if (schoolClass?.classType) return schoolClass.classType
return schoolClass?.createdBy ? "TEACHING" : "ADMIN"
}
function viewTask(task) {
openTaskDialog(task, "view")
}
function editTask(task) {
openTaskDialog(task, "edit")
}
function handleTaskAction(task) {
if (!selectedTeachingClass.value) {
viewTask(task)
return
}
editTask(task)
}
function createEmptyTask() {
return {
id: "",
taskKey: "",
project: "",
title: "",
background: "",
objectives: [""],
requirements: "",
materialName: "",
materialUrl: "",
trainingDataName: "",
trainingDataUrl: "",
guideManualName: "",
guideManualUrl: "",
aiManualName: "",
aiManualUrl: "",
stepOneQuestions: [],
steps: createBuiltinSteps(),
sort: 0,
enabled: true,
}
}
function supportsStepOneChoice(taskKey) {
return supportsStepOneChoiceQuestions(taskKey)
}
// 本校题库单选题(平台题 + 本校教师题,按学校过滤)
async function loadBankQuestions() {
const res = await getQuestions({ scope: "school", type: "SINGLE" })
return res?.data || []
}
function handleBankConfirm(questions) {
taskForm.stepOneQuestions = [...(taskForm.stepOneQuestions || []), ...questions]
proxy?.$modal?.msgSuccess(`已从题库添加 ${questions.length} 道题,点击“保存配置”后生效`)
}
function hasMaterial(slotKey) {
return isRealMaterial(taskForm[`${slotKey}Name`], taskForm[`${slotKey}Url`])
}
function materialLabel(slotKey) {
return materialSlots.find((slot) => slot.key === slotKey)?.label || "资料"
}
function normalizeTask(task) {
const material = normalizeMaterial(task.materialName, task.materialUrl)
const trainingData = normalizeMaterial(task.trainingDataName, task.trainingDataUrl)
const guideManual = normalizeMaterial(task.guideManualName, task.guideManualUrl)
const aiManual = normalizeMaterial(task.aiManualName, task.aiManualUrl)
return {
id: task.id,
taskKey: task.taskKey,
project: task.projectName,
title: task.taskName,
background: task.background || "",
objectives: parseArray(task.objectives, [""]),
requirements: task.requirements || "",
materialName: material.name,
materialUrl: material.url,
trainingDataName: trainingData.name,
trainingDataUrl: trainingData.url,
guideManualName: guideManual.name,
guideManualUrl: guideManual.url,
aiManualName: aiManual.name,
aiManualUrl: aiManual.url,
stepOneQuestions: parseStepOneChoiceQuestions(task.stepOneQuestions),
steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())),
sort: task.sort || 0,
enabled: task.enabled !== false,
}
}
function normalizeMaterial(materialName, materialUrl) {
const name = String(materialName || "").trim()
const url = normalizeMaterialUrl(materialUrl)
return isRealMaterial(name, url) ? { name, url } : { name: "", url: "" }
}
function isRealMaterial(materialName, materialUrl) {
const name = String(materialName || "").trim()
const url = String(materialUrl || "").trim()
return Boolean(name && url) && !(name === "案例资料.rar" && url === "/file/example.rar")
}
function normalizeMaterialUrl(rawUrl) {
const url = String(rawUrl || "").trim()
if (!url) {
return ""
}
try {
const parsed = new URL(url)
if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname) && parsed.pathname.startsWith("/file/")) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
// 相对地址无需转换。
}
return url
}
function parseArray(value, fallback) {
if (Array.isArray(value)) {
return value.length ? value : fallback
}
if (!value) {
return fallback
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) && parsed.length ? parsed : fallback
} catch (error) {
return String(value)
.split(/[;;||]/)
.map((item) => item.trim())
.filter(Boolean)
}
}
function normalizeSteps(steps) {
const source = Array.isArray(steps) ? steps : []
const result = source
.map((item, index) => {
const raw = item && typeof item === "object" ? item : {}
const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim()
const kind = raw.kind === "custom" ? "custom" : index < 4 ? "builtin" : "custom"
return createStep(name, kind, raw.id)
})
.filter((item) => item.name)
return result.length ? result : [createStep()]
}
function createBuiltinSteps() {
return [1, 2, 3, 4].map((index) => createStep("", "builtin", `builtin-${index}`))
}
function createStep(name = "", kind = "custom", id = "") {
return {
id: id || `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
kind,
}
}
function openTaskDialog(task, mode) {
dialogMode.value = mode
Object.assign(taskForm, cloneTask(task))
dialogVisible.value = true
}
function cloneTask(task) {
return {
...task,
objectives: [...(task.objectives?.length ? task.objectives : [""])],
steps: normalizeSteps(task.steps?.length ? task.steps : createBuiltinSteps()),
}
}
function addObjective() {
taskForm.objectives.push("")
}
function removeObjective(index) {
if (taskForm.objectives.length <= 1) return
taskForm.objectives.splice(index, 1)
}
async function handleMaterialChange(slotKey, file) {
const rawFile = file?.raw
if (!rawFile) return
const data = new FormData()
data.append("file", rawFile)
uploadingField.value = slotKey
try {
const res = await uploadTrainingTaskMaterial(data)
taskForm[`${slotKey}Name`] = res?.originalFilename || rawFile.name || file?.name || taskForm[`${slotKey}Name`]
taskForm[`${slotKey}Url`] = normalizeMaterialUrl(res?.url || res?.fileName || "")
proxy?.$modal?.msgSuccess(`${materialLabel(slotKey)}上传成功`)
} catch (error) {
proxy?.$modal?.msgError?.(`${materialLabel(slotKey)}上传失败`)
} finally {
uploadingField.value = ""
}
}
function clearMaterial(slotKey) {
taskForm[`${slotKey}Name`] = ""
taskForm[`${slotKey}Url`] = ""
}
async function submitTask() {
if (!selectedTeachingClass.value || !taskForm.taskKey) {
proxy?.$modal?.msgError?.("请选择教学班后再保存")
return
}
saving.value = true
try {
await saveClassTrainingTask(selectedTeachingClassId.value, taskForm.taskKey, buildPayload())
dialogVisible.value = false
proxy?.$modal?.msgSuccess("保存成功")
await queryTasks()
} finally {
saving.value = false
}
}
async function restoreTaskDefault() {
if (!selectedTeachingClass.value || !taskForm.taskKey) return
const confirmed = await confirmRestoreDefault("恢复此任务默认内容")
if (!confirmed) return
restoring.value = true
try {
await restoreClassTrainingTaskDefault(selectedTeachingClassId.value, taskForm.taskKey)
dialogVisible.value = false
proxy?.$modal?.msgSuccess("已恢复此任务的默认内容")
await queryTasks()
} finally {
restoring.value = false
}
}
async function restoreAllTasksDefaults() {
if (!selectedTeachingClass.value) return
const confirmed = await confirmRestoreDefault("恢复本班全部默认任务")
if (!confirmed) return
restoring.value = true
try {
await restoreClassTrainingTasksDefaults(selectedTeachingClassId.value)
proxy?.$modal?.msgSuccess("已恢复本班全部默认任务")
await queryTasks()
} finally {
restoring.value = false
}
}
async function openStudentDemo() {
if (!selectedTeachingClass.value) return
// 项目使用 hash 路由;显式带上 hash,避免新窗口先落到默认的教师首页。
const child = window.open("/#/student-demo", "_blank")
if (!child) {
proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试")
return
}
let ticket = ""
let childReady = false
const cleanup = () => window.removeEventListener("message", listener)
const sendTicket = () => {
if (!childReady || !ticket) return
child.postMessage({ type: "STUDENT_DEMO_TICKET", ticket }, window.location.origin)
cleanup()
}
const listener = (event) => {
if (event.origin !== window.location.origin || event.source !== child || event.data?.type !== "STUDENT_DEMO_READY") return
childReady = true
sendTicket()
}
window.addEventListener("message", listener)
const timeout = window.setTimeout(cleanup, 60 * 1000)
try {
const res = await createStudentDemoSession(selectedTeachingClassId.value)
ticket = res?.data?.ticket || ""
if (!ticket) throw new Error("missing demo ticket")
sendTicket()
} catch (error) {
window.clearTimeout(timeout)
cleanup()
child.close()
proxy?.$modal?.msgError?.("无法创建学生端讲解会话")
}
}
async function confirmRestoreDefault(action) {
try {
await proxy?.$modal?.confirm(`确认${action}吗?仅恢复当前教学班的实训任务内容,不影响学生已提交数据、成绩、进度及其他班级。`, "确认恢复默认内容")
return true
} catch (error) {
return false
}
}
function buildPayload() {
return {
projectName: taskForm.project,
taskKey: taskForm.taskKey,
taskName: taskForm.title,
background: taskForm.background,
objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)),
requirements: taskForm.requirements,
steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)),
materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl,
trainingDataName: taskForm.trainingDataName,
trainingDataUrl: taskForm.trainingDataUrl,
guideManualName: taskForm.guideManualName,
guideManualUrl: taskForm.guideManualUrl,
aiManualName: taskForm.aiManualName,
aiManualUrl: taskForm.aiManualUrl,
stepOneQuestions: JSON.stringify(taskForm.stepOneQuestions || []),
sort: taskForm.sort,
enabled: taskForm.enabled,
}
}
</script>
<style lang="scss" scoped>
/* ===== 筛选 / 操作条(白卡)===== */
.task-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-4);
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-card);
}
.toolbar-left {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.class-filter {
display: flex;
align-items: center;
gap: var(--sp-2);
.filter-label {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--text-body);
font-weight: 600;
font-size: var(--fs-body);
white-space: nowrap;
}
.class-select {
width: var(--field-w);
}
}
.search-box {
display: flex;
align-items: center;
gap: var(--sp-2);
.search-icon {
flex-shrink: 0;
color: var(--text-muted);
}
:deep(.el-input) {
width: var(--field-w);
}
}
.status-indicator {
:deep(.el-tag) {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-tag);
font-weight: 600;
}
}
/* ===== 工具栏按钮(外观由 teacher-light.scss 全局接管)===== */
.toolbar-btn {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-body);
font-weight: 600;
}
/* ===== 项目筛选(白卡)===== */
.project-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-4);
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-card);
.filter-label {
display: flex;
align-items: center;
gap: var(--sp-1);
color: var(--text-body);
font-size: var(--fs-body);
font-weight: 600;
svg {
color: var(--edu-primary);
}
}
}
.project-tabs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.project-tab {
display: flex;
align-items: center;
gap: var(--sp-2);
height: 36px;
padding: 0 var(--sp-3);
color: var(--text-body);
font-size: var(--fs-body);
font-weight: 500;
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
cursor: pointer;
transition: all 0.18s ease;
.tab-name {
font-weight: 600;
}
.tab-count {
padding: 0 var(--sp-2);
font-size: var(--fs-tag);
font-weight: 600;
line-height: 18px;
color: var(--text-muted);
background: #f9fafb;
border-radius: var(--r-control);
}
&:hover {
color: var(--text-strong);
border-color: #d1d5db;
background: #f9fafb;
}
&.active {
color: var(--edu-primary);
border-color: var(--edu-primary);
background: var(--edu-primary-soft);
.tab-count {
color: #ffffff;
background: var(--edu-primary);
}
}
}
.filter-result {
display: flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
padding: var(--sp-1) var(--sp-3);
color: var(--text-body);
font-size: var(--fs-tag);
font-weight: 600;
background: #f9fafb;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
}
/* ===== 任务卡片网格 ===== */
.task-grid {
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: var(--sp-4);
min-height: 300px;
}
.task-card {
position: relative;
padding: var(--sp-4);
border-radius: var(--r-card);
background: #ffffff;
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.18s ease;
overflow: hidden;
&:hover {
border-color: #d1d5db;
}
}
/* ===== 旧科技风装饰(角标 / 辉光 / 扫描线)整体隐藏 ===== */
.card-decoration,
.card-glow,
.scanline {
display: none;
}
.card-index {
position: absolute;
top: var(--sp-4);
right: var(--sp-4);
font-size: var(--fs-page);
font-weight: 700;
color: var(--edu-primary);
opacity: 0.18;
line-height: 1;
user-select: none;
}
.card-content {
position: relative;
z-index: 1;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.task-project {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-1) var(--sp-2);
color: var(--edu-primary);
font-size: var(--fs-tag);
font-weight: 600;
background: var(--edu-primary-soft);
border-radius: var(--r-control);
svg {
flex-shrink: 0;
}
}
/* ===== 状态标签:仅语义色 ===== */
.task-status {
flex-shrink: 0;
padding: var(--sp-1) var(--sp-2);
font-size: var(--fs-tag);
font-weight: 600;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-radius: var(--r-control);
&.enabled {
color: #2ba471;
background: rgba(43, 164, 113, 0.1);
}
}
.task-name {
margin: 0 0 var(--sp-3);
font-size: var(--fs-card);
font-weight: 700;
color: var(--text-strong);
line-height: 1.35;
}
.task-meta {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.meta-item {
display: flex;
align-items: center;
gap: var(--sp-1);
color: var(--text-muted);
font-size: var(--fs-tag);
svg {
flex-shrink: 0;
}
}
.card-actions {
position: relative;
z-index: 1;
margin-top: var(--sp-4);
padding-top: var(--sp-3);
border-top: 1px solid var(--border-color);
}
.edit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
width: 100%;
height: 36px;
color: var(--edu-primary);
background: transparent;
border: 1px solid var(--edu-primary);
font-size: var(--fs-body);
font-weight: 600;
&:hover,
&:focus {
color: var(--edu-primary-deep);
background: rgba(59, 130, 246, 0.1);
border-color: var(--edu-primary);
}
svg {
flex-shrink: 0;
}
}
.task-empty {
grid-column: 1 / -1;
// 空状态插图旧为科技蓝描边,统一为辅助灰
svg path,
svg rect {
stroke: var(--text-muted);
}
}
/* ===== 抽屉(append-to-body 传送至 body,scoped 属性仍在;令牌需在 .drawer-shell 重复定义)===== */
.training-task-drawer {
.el-drawer__body {
height: 100%;
padding: 0;
}
&.el-drawer {
border: none !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12) !important;
}
}
.drawer-shell {
/* ===== 颜色令牌 ===== */
--edu-primary: #3b82f6;
--edu-primary-deep: #2563eb;
--edu-primary-soft: #eff6ff;
--text-strong: #1f2937;
--text-body: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
/* ===== 字号 ===== */
--fs-page: 17px;
--fs-card: 14px;
--fs-body: 12px;
--fs-tag: 11px;
/* ===== 间距(4px 基准)===== */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
/* ===== 圆角 / 控件 ===== */
--r-card: 12px;
--r-control: 8px;
display: flex;
flex-direction: column;
height: 100%;
}
.drawer-header {
flex-shrink: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-color);
background: #ffffff;
}
.drawer-heading {
.drawer-kicker {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-1) var(--sp-2);
color: var(--edu-primary);
font-size: var(--fs-tag);
font-weight: 600;
background: var(--edu-primary-soft);
border-radius: var(--r-control);
svg {
flex-shrink: 0;
}
}
h3 {
margin: var(--sp-3) 0 var(--sp-2);
font-size: var(--fs-page);
font-weight: 700;
color: var(--text-strong);
}
p {
margin: 0;
color: var(--text-muted);
font-size: var(--fs-body);
line-height: 1.5;
}
}
.drawer-close-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--text-body);
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
cursor: pointer;
transition: all 0.18s ease;
&:hover {
color: var(--text-strong);
background: #f9fafb;
border-color: #d1d5db;
}
svg {
flex-shrink: 0;
}
}
.drawer-identity {
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 1.5fr 120px;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-color);
background: #ffffff;
}
.identity-item {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
>svg {
flex-shrink: 0;
color: var(--edu-primary);
}
.identity-content {
min-width: 0;
}
.identity-label {
display: block;
margin-bottom: var(--sp-1);
color: var(--text-muted);
font-size: var(--fs-tag);
font-weight: 600;
}
strong {
display: block;
overflow: hidden;
color: var(--text-strong);
font-size: var(--fs-body);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 任务状态:仅语义色 ===== */
.status-value {
color: #ef4444;
&.enabled {
color: #2ba471;
}
}
}
.task-form {
flex: 1;
min-height: 0;
padding: var(--sp-4) var(--sp-5);
overflow-y: auto;
.el-form-item {
margin-bottom: var(--sp-5);
}
}
.drawer-section {
padding: var(--sp-4);
margin-bottom: var(--sp-4);
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-card);
}
.section-title {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
.section-number {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: var(--fs-body);
font-weight: 600;
color: var(--edu-primary);
background: var(--edu-primary-soft);
border-radius: var(--r-control);
}
.section-info {
h4 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-card);
font-weight: 700;
color: var(--text-strong);
}
p {
margin: 0;
color: var(--text-muted);
font-size: var(--fs-tag);
}
}
}
.objective-list {
width: 100%;
}
.objective-row {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
.objective-number {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: var(--fs-tag);
font-weight: 600;
color: var(--edu-primary);
background: var(--edu-primary-soft);
border-radius: var(--r-control);
}
.el-input {
flex: 1;
}
}
.remove-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: #ef4444;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
cursor: pointer;
transition: all 0.18s ease;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.4);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.add-objective-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
height: 36px;
padding: 0 var(--sp-4);
color: var(--edu-primary);
font-size: var(--fs-body);
font-weight: 600;
background: transparent;
border: 1px dashed var(--edu-primary);
border-radius: var(--r-control);
cursor: pointer;
transition: all 0.18s ease;
&:hover {
color: var(--edu-primary-deep);
background: var(--edu-primary-soft);
border-color: var(--edu-primary);
}
}
.material-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: var(--sp-3);
margin-bottom: var(--sp-4);
background: #f9fafb;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
}
.material-info {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
>svg {
flex-shrink: 0;
color: var(--edu-primary);
}
.material-text {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.material-label {
flex-shrink: 0;
color: var(--text-strong);
font-size: var(--fs-body);
font-weight: 600;
}
.material-link {
overflow: hidden;
color: var(--edu-primary);
font-weight: 600;
font-size: var(--fs-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.material-placeholder {
color: var(--text-muted);
font-style: italic;
font-size: var(--fs-body);
}
}
.material-ops {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--sp-2);
.clear-material-btn {
height: 32px;
font-size: var(--fs-body);
font-weight: 600;
}
}
.step-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.step-item {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.step-connector {
flex-shrink: 0;
svg path {
stroke: var(--text-muted);
}
}
.step-content {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1;
/* ===== 步骤序号:语义绿 ===== */
.step-number {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: var(--fs-tag);
font-weight: 600;
color: #2ba471;
background: rgba(43, 164, 113, 0.1);
border-radius: var(--r-control);
}
.el-input {
flex: 1;
}
.step-question-btn {
flex-shrink: 0;
height: 32px;
font-size: var(--fs-body);
font-weight: 600;
}
.step-question-tag {
flex-shrink: 0;
color: var(--text-muted);
font-size: var(--fs-tag);
white-space: nowrap;
}
}
.drawer-footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5);
background: #ffffff;
border-top: 1px solid var(--border-color);
}
// 主按钮统一 outline;自定义 SVG 图标需显式 gap
.save-btn {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--edu-primary);
background: transparent;
border: 1px solid var(--edu-primary);
&:hover,
&:focus {
color: var(--edu-primary-deep);
background: rgba(59, 130, 246, 0.1);
border-color: var(--edu-primary);
}
svg {
flex-shrink: 0;
}
}
// 响应式
@media (max-width: 1280px) {
.task-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-result {
margin-left: 0;
width: 100%;
justify-content: center;
}
.task-grid {
grid-template-columns: 1fr;
}
.drawer-identity {
grid-template-columns: 1fr;
}
}
</style>