@ -156,6 +156,17 @@
{ { task . steps ? . length || 0 } } 个步骤
< / span >
< / div >
< div v-if ="resolveStudentPage(task)" class="task-binding" >
< svg class = "binding-icon" width = "12" height = "12" viewBox = "0 0 24 24" fill = "none"
stroke = "currentColor" stroke - width = "2" >
< path d = "M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" / >
< path d = "M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" / >
< / svg >
< span class = "binding-label" > 对应学生页 : < / span >
< span class = "binding-value" > { { resolveStudentPage ( task ) . label } } < / span >
< span class = "binding-path" > { { resolveStudentPage ( task ) . path } } < / span >
< / div >
< / div >
< div class = "card-actions" @ click.stop >
@ -311,31 +322,39 @@
< p > 上传案例资料 , 配置实训步骤 < / p >
< / div >
< / div >
< div 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 v-if ="hasTaskMaterial" class="material-link" > {{ taskForm.materialName
} } < / span >
< span v -else class = "material-placeholder" > 暂未上传案例资料 < / span >
< div class = "material-grid" >
< div v-for ="slot in materialSlots" :key="slot.key" class="material-row" >
< div class = "material-info" >
< el -icon class = "material-icon" :size ="20" >
< component :is ="slot.icon" / >
< / e l - i c o n >
< div class = "material-text" >
< span class = "material-label" > { { slot . label } } < / span >
< span v -if = " slot.material.name " class = "material-link"
: title = "slot.material.name" > { { slot . material . name } } < / span >
< span v -else class = "material-placeholder" > 暂未上传 { { slot . label } } < / span >
< / div >
< / div >
< div class = "material-actions" >
< el -button v -if = " slot.material.url " type = "primary" plain size = "small"
class = "preview-btn" @ click = "previewMaterial(slot)" >
< el -icon >
< View / >
< / e l - i c o n >
预览
< / e l - b u t t o n >
< el -upload v -if = " ! isReadonly " action = "#" :auto-upload ="false" :show-file-list ="false"
: on - change = "(file) => handleMaterialChange(file, slot)" >
< el -button type = "primary" size = "small" :loading ="slot.uploading"
class = "upload-btn" >
< el -icon >
< Upload / >
< / e l - i c o n >
{ { slot . material . name ? "重新上传" : "上传" } }
< / e l - b u t t o n >
< / e l - u p l o a d >
< / div >
< / div >
< el -upload v -if = " ! isReadonly " action = "#" :auto-upload ="false" :show-file-list ="false"
: on - change = "handleMaterialChange" >
< el -button type = "primary" :loading ="materialUploading" 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 >
{ { materialUploading ? "上传中..." : "上传案例资料" } }
< / e l - b u t t o n >
< / e l - u p l o a d >
< / div >
< el -form -item label = "步骤名称:" >
@ -399,7 +418,19 @@
< / footer >
< / div >
< / e l - d r a w e r >
< / div >
<!-- 资料预览对话框 -- >
< el -dialog v -model = " previewVisible " :title ="previewTitle" width = "min(920px, 92vw)" destroy -on -close
append - to - body class = "material-preview-dialog" >
< div v-if ="previewMaterialInfo?.kind === 'image'" class="image-preview" >
< img :src ="previewMaterialInfo.url" :alt ="previewMaterialInfo.name" / >
< / div >
< iframe v -else -if = " previewMaterialInfo ? .kind = = = ' pdf ' " class = "pdf-preview"
: src = "previewMaterialInfo.url" : title = "previewMaterialInfo.name" > < / iframe >
< div v -else class = "preview-empty" > 该资料无法在线预览 , 请下载后查看 < / div >
< / e l - d i a l o g >
< / div >
< / template >
< script setup >
@ -407,6 +438,8 @@ import { listTrainingTasks, listClassTrainingTasks, saveClassTrainingTask, resto
import { createStudentDemoSession } from "@/api/studentDemo"
import * as indexApi from "@/api/teacher"
import useUserStore from "@/store/modules/user"
import { Document , DataAnalysis , Reading , MagicStick , View , Upload } from "@element-plus/icons-vue"
import { trainingTaskKeyByPath , resolveTrainingTaskKey } from "@/views/training/taskKeyMap"
const { proxy } = getCurrentInstance ( )
const userStore = useUserStore ( )
@ -417,7 +450,6 @@ const loading = ref(false)
const classesLoading = ref ( false )
const saving = ref ( false )
const restoring = ref ( false )
const materialUploading = ref ( false )
const tasks = ref ( [ ] )
const classList = ref ( [ ] )
const selectedTeachingClassId = ref ( "" )
@ -426,7 +458,6 @@ const dialogMode = ref("view")
const taskForm = reactive ( createEmptyTask ( ) )
const currentUserId = computed ( ( ) => userStore . userInfo . userId )
const isReadonly = computed ( ( ) => dialogMode . value === "view" )
const hasTaskMaterial = computed ( ( ) => isRealMaterial ( taskForm . materialName , taskForm . materialUrl ) )
const projects = computed ( ( ) => [ ... new Set ( tasks . value . map ( ( task ) => task . project ) . filter ( Boolean ) ) ] )
const projectOptions = computed ( ( ) => {
return projects . value . map ( ( project ) => ( {
@ -452,6 +483,78 @@ const filteredTasks = computed(() => {
} )
} )
/ / 四 个 资 料 位 ( 与 学 生 端 T r a i n i n g M a t e r i a l B u t t o n 字 段 一 一 对 应 )
const MATERIAL _DEFINITIONS = [
{ key : "caseMaterial" , label : "案例资料" , icon : markRaw ( Document ) } ,
{ key : "trainingData" , label : "实训数据" , icon : markRaw ( DataAnalysis ) } ,
{ key : "guideManual" , label : "实训指导手册" , icon : markRaw ( Reading ) } ,
{ key : "aiManual" , label : "AI辅助指导手册" , icon : markRaw ( MagicStick ) } ,
]
const materialSlots = ref ( MATERIAL _DEFINITIONS . map ( ( definition ) => reactive ( {
... definition ,
uploading : false ,
material : { name : "" , url : "" } ,
} ) ) )
function refreshMaterialSlots ( ) {
materialSlots . value . forEach ( ( slot ) => {
const pair = materialFieldPair ( slot . key )
if ( ! pair ) return
slot . material . name = taskForm [ pair . name ] || ""
slot . material . url = taskForm [ pair . url ] || ""
} )
}
watch ( taskForm , refreshMaterialSlots , { deep : true , immediate : true } )
const previewVisible = ref ( false )
const previewTitle = ref ( "" )
const previewMaterialInfo = ref ( null )
function resolvePreviewKind ( name , url ) {
if ( ! url ) return null
const ext = String ( name || url ) . split ( /[?#]/ ) [ 0 ] . split ( "." ) . pop ( ) . toLowerCase ( )
if ( [ "jpg" , "jpeg" , "png" , "gif" , "webp" , "bmp" , "svg" ] . includes ( ext ) ) return "image"
if ( ext === "pdf" ) return "pdf"
return "other"
}
function previewMaterial ( slot ) {
if ( ! slot . material ? . url ) {
proxy ? . $modal ? . msgWarning ? . ( ` 请先上传 ${ slot . label } ` )
return
}
const kind = resolvePreviewKind ( slot . material . name , slot . material . url )
previewTitle . value = ` ${ slot . label } - ${ slot . material . name || "预览" } `
previewMaterialInfo . value = { kind , url : slot . material . url , name : slot . material . name }
previewVisible . value = true
}
/ / 根 据 t a s k K e y 反 查 t r a i n i n g T a s k K e y B y P a t h , 得 到 每 个 任 务 对 应 的 学 生 端 路 径
const studentPathByTaskKey = ( ( ) => {
const pathMap = { }
for ( const [ path , key ] of Object . entries ( trainingTaskKeyByPath || { } ) ) {
if ( ! pathMap [ key ] ) pathMap [ key ] = [ ]
if ( ! pathMap [ key ] . includes ( path ) ) pathMap [ key ] . push ( path )
}
return pathMap
} ) ( )
function resolveStudentPage ( task ) {
const taskKey = resolveTrainingTaskKey ( { meta : { pageKey : task ? . taskKey } , path : "" } , task ? . taskKey )
if ( ! taskKey ) return null
const paths = studentPathByTaskKey [ taskKey ] || [ ]
if ( ! paths . length ) return null
const primaryPath = paths [ 0 ] || "/training/task/" + taskKey
return {
taskKey ,
path : primaryPath ,
paths ,
label : task ? . title || taskKey ,
}
}
onMounted ( ( ) => {
setTrainingTaskMainTheme ( true )
loadTeachingClasses ( )
@ -564,6 +667,12 @@ function createEmptyTask() {
requirements : "" ,
materialName : "" ,
materialUrl : "" ,
trainingDataName : "" ,
trainingDataUrl : "" ,
guideManualName : "" ,
guideManualUrl : "" ,
aiManualName : "" ,
aiManualUrl : "" ,
steps : createBuiltinSteps ( ) ,
sort : 0 ,
enabled : true ,
@ -572,6 +681,9 @@ function createEmptyTask() {
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 ,
@ -582,6 +694,12 @@ function normalizeTask(task) {
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 ,
steps : normalizeSteps ( parseArray ( task . steps , createBuiltinSteps ( ) ) ) ,
sort : task . sort || 0 ,
enabled : task . enabled !== false ,
@ -693,21 +811,33 @@ function removeStep(index) {
taskForm . steps . splice ( index , 1 )
}
async function handleMaterialChange ( file ) {
function materialFieldPair ( key ) {
const mapping = {
caseMaterial : { name : "materialName" , url : "materialUrl" } ,
trainingData : { name : "trainingDataName" , url : "trainingDataUrl" } ,
guideManual : { name : "guideManualName" , url : "guideManualUrl" } ,
aiManual : { name : "aiManualName" , url : "aiManualUrl" } ,
}
return mapping [ key ]
}
async function handleMaterialChange ( file , slot ) {
const rawFile = file ? . raw
if ( ! rawFile ) return
const pair = materialFieldPair ( slot . key )
if ( ! pair ) return
const data = new FormData ( )
data . append ( "file" , rawFile )
materialUploading . value = true
slot. uploading = true
try {
const res = await uploadTrainingTaskMaterial ( data )
taskForm . materialName = res ? . originalFilename || rawFile . name || file ? . name || taskForm . materialName
taskForm . materialUrl = normalizeMaterialUrl ( res ? . url || res ? . fileName || "" )
proxy ? . $modal ? . msgSuccess ( "案例资料上传成功" )
taskForm [pair . name ] = res ? . originalFilename || rawFile . name || file ? . name || taskForm [pair . name ]
taskForm [pair . url ] = normalizeMaterialUrl ( res ? . url || res ? . fileName || "" )
proxy ? . $modal ? . msgSuccess ? . ( ` ${ slot . label } 上传成功 ` )
} catch ( error ) {
proxy ? . $modal ? . msgError ? . ( "案例资料上传失败" )
proxy ? . $modal ? . msgError ? . ( ` ${ slot . label } 上传失败 ` )
} finally {
materialUploading. value = false
slot. uploading = false
}
}
@ -758,7 +888,11 @@ async function restoreAllTasksDefaults() {
async function openStudentDemo ( ) {
if ( ! selectedTeachingClass . value ) return
const child = window . open ( "/student-demo" , "_blank" )
/ / V u e R o u t e r 4 + c r e a t e W e b H a s h H i s t o r y ( ) 只 看 h a s h 、 不 看 U R L 路 径 。
/ / 直 接 / s t u d e n t - d e m o 会 被 解 释 成 空 h a s h → 路 由 / → 跳 登 录 或 教 师 首 页 ,
/ / S t u d e n t D e m o 组 件 根 本 不 会 挂 载 , 握 手 / 兑 换 / 切 身 份 这 一 连 串 动 作
/ / 不 会 发 生 , 新 窗 口 只 会 停 留 在 教 师 侧 。 强 制 带 h a s h 才 能 稳 定 落 到 / s t u d e n t - d e m o 。
const child = window . open ( "/#/student-demo" , "_blank" )
if ( ! child ) {
proxy ? . $modal ? . msgError ? . ( "浏览器拦截了演示窗口,请允许弹窗后重试" )
return
@ -811,6 +945,12 @@ function buildPayload() {
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 ,
sort : taskForm . sort ,
enabled : taskForm . enabled ,
}
@ -1107,6 +1247,45 @@ function buildPayload() {
}
}
. task - binding {
display : flex ;
align - items : center ;
flex - wrap : wrap ;
gap : var ( -- sp - 1 ) ;
margin - top : var ( -- sp - 3 ) ;
padding : var ( -- sp - 2 ) ;
color : var ( -- text - body ) ;
font - size : var ( -- fs - tag ) ;
background : # f3f4f6 ;
border : 1 px dashed var ( -- border - color ) ;
border - radius : var ( -- r - control ) ;
. binding - icon {
color : var ( -- edu - primary ) ;
flex - shrink : 0 ;
}
. binding - label {
color : var ( -- text - muted ) ;
font - weight : 600 ;
}
. binding - value {
color : var ( -- text - strong ) ;
font - weight : 600 ;
}
. binding - path {
margin - left : auto ;
padding : 0 6 px ;
color : var ( -- edu - primary ) ;
font - family : ui - monospace , SFMono - Regular , "SF Mono" , Menlo , Consolas , monospace ;
font - size : 10 px ;
background : var ( -- edu - primary - soft ) ;
border - radius : 4 px ;
}
}
. card - actions {
position : relative ;
z - index : 1 ;
@ -1450,32 +1629,62 @@ function buildPayload() {
}
}
. material - grid {
display : grid ;
grid - template - columns : 1 fr 1 fr ;
gap : var ( -- sp - 3 ) ;
margin - bottom : var ( -- sp - 4 ) ;
}
. material - row {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : var ( -- sp - 4 ) ;
gap : var ( -- sp - 3 ) ;
padding : var ( -- sp - 3 ) ;
margin - bottom : var ( -- sp - 4 ) ;
background : # f9fafb ;
border : 1 px solid var ( -- border - color ) ;
border - radius : var ( -- r - control ) ;
transition : border - color 0.18 s ease , background 0.18 s ease ;
}
. material - row : hover {
border - color : rgba ( 59 , 130 , 246 , 0.4 ) ;
background : # ffffff ;
}
. material - info {
display : flex ;
align - items : center ;
gap : var ( -- sp - 2 ) ;
min - width : 0 ;
> svg {
. material - icon {
flex - shrink : 0 ;
color : var ( -- edu - primary ) ;
}
. material - text {
display : flex ;
flex - direction : column ;
min - width : 0 ;
}
. material - label {
color : var ( -- text - muted ) ;
font - size : var ( -- fs - tag ) ;
font - weight : 600 ;
margin - bottom : 2 px ;
}
. material - link {
overflow : hidden ;
color : var ( -- edu - primary ) ;
font - weight : 600 ;
font - size : var ( -- fs - body ) ;
text - overflow : ellipsis ;
white - space : nowrap ;
max - width : 220 px ;
}
. material - placeholder {
@ -1485,6 +1694,50 @@ function buildPayload() {
}
}
. material - actions {
display : flex ;
align - items : center ;
gap : var ( -- sp - 2 ) ;
flex - shrink : 0 ;
}
. material - actions . upload - btn ,
. material - actions . preview - btn {
display : inline - flex ;
align - items : center ;
gap : var ( -- sp - 1 ) ;
}
. image - preview {
display : flex ;
justify - content : center ;
max - height : 68 vh ;
overflow : auto ;
background : # 0b1 720 ;
border - radius : 6 px ;
}
. image - preview img {
display : block ;
max - width : 100 % ;
height : auto ;
object - fit : contain ;
}
. pdf - preview {
width : 100 % ;
height : 68 vh ;
border : 0 ;
background : # fff ;
}
. preview - empty {
padding : 60 px 0 ;
text - align : center ;
color : var ( -- text - muted ) ;
font - size : var ( -- fs - body ) ;
}
. step - list {
display : flex ;
flex - direction : column ;
@ -1583,6 +1836,10 @@ function buildPayload() {
. drawer - identity {
grid - template - columns : 1 fr ;
}
. material - grid {
grid - template - columns : 1 fr ;
}
}
< / style >