|
|
<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>
|
|
|
|