feat(studentDemo): implement demo session handling and role management

Added functionality to manage student demo sessions, ensuring the role is fixed to student to prevent unauthorized access. Introduced a new method to unwrap demo payloads and save session data securely. Updated navigation logic to redirect users appropriately based on their role, enhancing the user experience during demo sessions.
fix/exam-question-access-material-excel-delete-draft
qinzhenpen 2 weeks ago
parent df71737209
commit 6edf32a7b5

@ -7,6 +7,23 @@ import { exchangeStudentDemoSession } from "@/api/studentDemo";
import { saveStudentDemoSession } from "@/utils/studentDemo"; import { saveStudentDemoSession } from "@/utils/studentDemo";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
// 学生端演示登录态的角色:固定为学生 (roleId === 4),避免后端返回
// 教师身份导致跳转到教师首页。后端若返回 demoTeachingClassId,会被
// 透传保留,登录页/Navbar/AppMain 可据此识别为"演示学生"。
const STUDENT_DEMO_ROLE_ID = "4";
function unwrapDemoPayload(payload) {
if (!payload || typeof payload !== "object") {
return { userInfo: { roleId: STUDENT_DEMO_ROLE_ID }, token: "" };
}
// 兼容两种返回结构:
// 1) { token, roleId, userId, ... } —— 扁平结构
// 2) { token, userInfo: { roleId, ... } } —— userInfo 嵌套
const inner = payload.userInfo && typeof payload.userInfo === "object" ? payload.userInfo : payload;
const token = inner.token || payload.token || "";
return { userInfo: { ...inner, token, roleId: STUDENT_DEMO_ROLE_ID }, token };
}
const userStore = useUserStore(); const userStore = useUserStore();
const message = ref("正在连接教师演示会话…"); const message = ref("正在连接教师演示会话…");
let readyTimer; let readyTimer;
@ -15,15 +32,36 @@ function notifyReady() {
if (window.opener) window.opener.postMessage({ type: "STUDENT_DEMO_READY" }, window.location.origin); if (window.opener) window.opener.postMessage({ type: "STUDENT_DEMO_READY" }, window.location.origin);
} }
function applyDemoSession(demoPayload) {
const { userInfo, token } = unwrapDemoPayload(demoPayload);
// 保存到 sessionStorage(getUserInfo/getToken 优先从这里读取)
// 注意:演示 session 只放在新窗口的 sessionStorage,避免覆盖
// 教师主窗口的 cookie/sessionStorage 而把教师侧误切到学生身份。
saveStudentDemoSession(userInfo);
// 立即更新 Pinia,避免 reload 之前出现教师内容闪烁
userStore.token = token;
userStore.userInfo = userInfo;
}
function receiveTicket(event) { function receiveTicket(event) {
if (event.origin !== window.location.origin || event.source !== window.opener || event.data?.type !== "STUDENT_DEMO_TICKET") return; if (event.origin !== window.location.origin || event.source !== window.opener || event.data?.type !== "STUDENT_DEMO_TICKET") return;
exchangeStudentDemoSession(event.data.ticket).then((res) => { exchangeStudentDemoSession(event.data.ticket).then((res) => {
saveStudentDemoSession(res.data); if (!res || !res.data) {
userStore.token = res.data.token; message.value = "演示服务返回数据为空,请稍后重试";
userStore.userInfo = res.data; return;
}
applyDemoSession(res.data);
window.clearInterval(readyTimer); window.clearInterval(readyTimer);
window.removeEventListener("message", receiveTicket); window.removeEventListener("message", receiveTicket);
window.location.hash = "#/index"; // 跳转学生端基础课首页 #/foundation/new-product-survey:
// roleId 已被改写为 4,filterDynamicRoutes 会加载 student 路由,
// 避开 /index 的角色分发逻辑(role==='3' 会落到 <teacher/>),
// 强制落到学生工作台,避免任何教师侧兜底。
const targetHash = "#/foundation/new-product-survey";
if (window.location.hash !== targetHash) {
// 先改 hash 再 reload,避免仅 hash 变化时浏览器不重新加载
window.location.hash = targetHash;
}
window.location.reload(); window.location.reload();
}).catch(() => { message.value = "演示链接已失效,请返回教师端重新打开。"; }); }).catch(() => { message.value = "演示链接已失效,请返回教师端重新打开。"; });
} }

@ -486,7 +486,7 @@ const beforeImportStudents = (file) => {
return true return true
} }
const getClassList = () => { const getClassList = () => {
indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId }).then((res) => { return indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId }).then((res) => {
classList.value = res.data classList.value = res.data
}) })
} }
@ -542,11 +542,15 @@ const initPassword = (row, status) => {
} }
onMounted(async () => { onMounted(async () => {
await userStore.ensureSchoolProductConfig().catch(() => null) await userStore.ensureSchoolProductConfig().catch(() => null)
getList()
getClassList()
if (!isTeacherRosterMode.value) { if (!isTeacherRosterMode.value) {
getFacultyList() getFacultyList()
} }
await getClassList()
// 默认展示第一个班级数据:把 select 绑到第一个教学班并立即拉取学生列表
if (!paramsquery.value.schoolClassId && studentClassOptions.value.length) {
paramsquery.value.schoolClassId = studentClassOptions.value[0].schoolClassId
}
getList()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

@ -102,8 +102,169 @@ function isStepNavigation(element) {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/*
* 教师端任务评阅右侧预览:
* 学生页面已通过 dynamic component 嵌入,保留学生端样式;
* 仅保留步骤导航与步骤内容,隐藏标题区、任务简介、AI 助手与所有顶部/底部操作按钮(仅查看)。
*/
.student-task-readonly-preview {
background: #ffffff;
font-family: var(--student-font-base, "PingFang SC", "Source Han Sans SC", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif);
}
/* ===== 1. 隐藏 AI 助手、AI 评测、独立的 TrainingTaskBrief 组件(保持原行为) ===== */
.student-task-readonly-preview :deep(.training-ai-sidebar), .student-task-readonly-preview :deep(.training-ai-sidebar),
.student-task-readonly-preview :deep(.training-ai-evaluation), .student-task-readonly-preview :deep(.training-ai-evaluation),
.student-task-readonly-preview :deep(.training-task-brief) { display: none !important; } .student-task-readonly-preview :deep(.training-task-brief) { display: none !important; }
/* ===== 2. 隐藏学生页顶部 chrome(标题、任务简介、展开/收起按钮、顶部工具栏等) ===== */
.student-task-readonly-preview :deep(.task-header),
.student-task-readonly-preview :deep(.task-head-main),
.student-task-readonly-preview :deep(.task-brief),
.student-task-readonly-preview :deep(.task-brief-toggle),
.student-task-readonly-preview :deep(.task-brief-more),
.student-task-readonly-preview :deep(.workbench-head),
.student-task-readonly-preview :deep(.workbench-head--actions-only),
.student-task-readonly-preview :deep(.workbench-actions),
.student-task-readonly-preview :deep(.training-task-toolbar),
.student-task-readonly-preview :deep(.training-legacy-actions),
.student-task-readonly-preview :deep(.new-product-survey-toolbar),
.student-task-readonly-preview :deep(.comprehensive-training-toolbar),
.student-task-readonly-preview :deep(.release-channel-toolbar),
.student-task-readonly-preview :deep(.purchase-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.process-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.factors-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.channel-toolbar),
.student-task-readonly-preview :deep(.supplier-toolbar),
.student-task-readonly-preview :deep(.promotion-toolbar),
.student-task-readonly-preview :deep(.hardware-workbench-head),
.student-task-readonly-preview :deep(.legacy-toolbar),
.student-task-readonly-preview :deep(.material-actions) { display: none !important; }
/* ===== 2.1 旧版实训页:隐藏 TrainingIntro 组件的标题/简介,仅保留其步骤导航 ===== */
.student-task-readonly-preview :deep(.training-intro__head),
.student-task-readonly-preview :deep(.training-intro__body) { display: none !important; }
.student-task-readonly-preview :deep(.training-intro) {
margin-bottom: 12px !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow: visible !important;
}
.student-task-readonly-preview :deep(.training-intro__steps.step-indicator) { margin: 4px 0 18px !important; }
/* ===== 2.2 旧版实训页:top_item 是 section 切换 tab,保留作为步骤导航 ===== */
.student-task-readonly-preview :deep(.top_item) {
margin: 0 0 14px !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
}
/* ===== 3. 让学生页面把 AI 侧栏所在的网格列收回,让预览通栏展示 ===== */
.student-task-readonly-preview :deep(.student-training-shell) { grid-template-columns: minmax(0, 1fr) !important; } .student-task-readonly-preview :deep(.student-training-shell) { grid-template-columns: minmax(0, 1fr) !important; }
/* ===== 4. 紧贴 app-main 内边距,复用学生端整体留白 ===== */
.student-task-readonly-preview :deep(.student-training-shell) > main { padding: 14px 16px 24px !important; }
/* ===== 5. 步骤导航横排、并与学生端胶囊样式一致 ===== */
.student-task-readonly-preview :deep(.step-indicator),
.student-task-readonly-preview :deep(.comprehensive-step-nav),
.student-task-readonly-preview :deep(.comprehensive-step-strip),
.student-task-readonly-preview :deep(.step-tabs),
.student-task-readonly-preview :deep(.flow-steps),
.student-task-readonly-preview :deep(.flow-rail),
.student-task-readonly-preview :deep(.training-intro__steps) {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
gap: 0 !important;
width: 100% !important;
max-width: 100% !important;
margin: 4px 0 18px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: thin !important;
-webkit-overflow-scrolling: touch !important;
}
/* 步骤胶囊:固定宽度并排,不让任何一条占满整行 */
.student-task-readonly-preview :deep(.step-indicator) .step-tab,
.student-task-readonly-preview :deep(.comprehensive-step-strip) .step-tab,
.student-task-readonly-preview :deep(.step-tabs) .step-tab,
.student-task-readonly-preview :deep(.step-tabs) .step-pill,
.student-task-readonly-preview :deep(.step-indicator) .flow-pill,
.student-task-readonly-preview :deep(.flow-steps) .step-pill,
.student-task-readonly-preview :deep(.flow-rail) .flow-step,
.student-task-readonly-preview :deep(.training-intro__steps) .step-tab {
flex: 0 0 auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
flex-basis: auto !important;
width: auto !important;
min-width: 132px !important;
max-width: 220px !important;
margin: 0 !important;
}
.student-task-readonly-preview :deep(.comprehensive-step-strip) {
width: max-content !important;
min-width: 100% !important;
}
.student-task-readonly-preview :deep(.step-connector),
.student-task-readonly-preview :deep(.step-arrow),
.student-task-readonly-preview :deep(.flow-arrow) {
flex: 0 0 22px !important;
width: 22px !important;
min-width: 22px !important;
display: inline-block !important;
}
/* ===== 6. 步骤内容卡片:保留学生端白卡 + 灰边样式 ===== */
.student-task-readonly-preview :deep(.step-card),
.student-task-readonly-preview :deep(.process-step-card),
.student-task-readonly-preview :deep(.factor-step-card),
.student-task-readonly-preview :deep(.step-content > .step-card),
.student-task-readonly-preview :deep(.training-step-area) {
border: 1px solid #e5e7eb !important;
border-radius: 12px !important;
background: #ffffff !important;
box-shadow: none !important;
}
/* ===== 7. 关闭底部"提交/导出/清空"等动作按钮区(仅查看,不需要学生端的提交动作) ===== */
.student-task-readonly-preview :deep(.task-actions),
.student-task-readonly-preview :deep(.task-actions-row),
.student-task-readonly-preview :deep(.taskfooter),
.student-task-readonly-preview :deep(.step-footer),
.student-task-readonly-preview :deep(.step-actions),
.student-task-readonly-preview :deep(.footer-actions) {
pointer-events: none !important;
opacity: 0.55 !important;
filter: grayscale(0.4) !important;
}
/* ===== 8. 禁用所有表单交互,确保仅查看 ===== */
.student-task-readonly-preview :deep(input),
.student-task-readonly-preview :deep(textarea),
.student-task-readonly-preview :deep(select),
.student-task-readonly-preview :deep(.el-input__inner),
.student-task-readonly-preview :deep(.el-textarea__inner),
.student-task-readonly-preview :deep(.el-select) { pointer-events: none !important; }
/* ===== 9. 隐藏通用浮层与弹窗,避免误触 ===== */
.student-task-readonly-preview :deep(.gdialog),
.student-task-readonly-preview :deep(.popModal),
.student-task-readonly-preview :deep(.el-popper),
.student-task-readonly-preview :deep(.el-overlay) { display: none !important; }
</style> </style>

@ -156,6 +156,17 @@
{{ task.steps?.length || 0 }} 个步骤 {{ task.steps?.length || 0 }} 个步骤
</span> </span>
</div> </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>
<div class="card-actions" @click.stop> <div class="card-actions" @click.stop>
@ -311,32 +322,40 @@
<p>上传案例资料,配置实训步骤</p> <p>上传案例资料,配置实训步骤</p>
</div> </div>
</div> </div>
<div class="material-row"> <div class="material-grid">
<div v-for="slot in materialSlots" :key="slot.key" class="material-row">
<div class="material-info"> <div class="material-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" <el-icon class="material-icon" :size="20">
stroke-width="2"> <component :is="slot.icon" />
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /> </el-icon>
<polyline points="14 2 14 8 20 8" />
</svg>
<div class="material-text"> <div class="material-text">
<span v-if="hasTaskMaterial" class="material-link">{{ taskForm.materialName <span class="material-label">{{ slot.label }}</span>
}}</span> <span v-if="slot.material.name" class="material-link"
<span v-else class="material-placeholder">暂未上传案例资料</span> :title="slot.material.name">{{ slot.material.name }}</span>
<span v-else class="material-placeholder">暂未上传{{ slot.label }}</span>
</div> </div>
</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 />
</el-icon>
预览
</el-button>
<el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false" <el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false"
:on-change="handleMaterialChange"> :on-change="(file) => handleMaterialChange(file, slot)">
<el-button type="primary" :loading="materialUploading" class="upload-btn"> <el-button type="primary" size="small" :loading="slot.uploading"
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="upload-btn">
stroke-width="2"> <el-icon>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /> <Upload />
<polyline points="17 8 12 3 7 8" /> </el-icon>
<line x1="12" y1="3" x2="12" y2="15" /> {{ slot.material.name ? "重新上传" : "上传" }}
</svg>
{{ materialUploading ? "上传中..." : "上传案例资料" }}
</el-button> </el-button>
</el-upload> </el-upload>
</div> </div>
</div>
</div>
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
<div class="step-list"> <div class="step-list">
@ -399,6 +418,18 @@
</footer> </footer>
</div> </div>
</el-drawer> </el-drawer>
<!-- 资料预览对话框 -->
<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>
</el-dialog>
</div> </div>
</template> </template>
@ -407,6 +438,8 @@ import { listTrainingTasks, listClassTrainingTasks, saveClassTrainingTask, resto
import { createStudentDemoSession } from "@/api/studentDemo" import { createStudentDemoSession } from "@/api/studentDemo"
import * as indexApi from "@/api/teacher" import * as indexApi from "@/api/teacher"
import useUserStore from "@/store/modules/user" 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 { proxy } = getCurrentInstance()
const userStore = useUserStore() const userStore = useUserStore()
@ -417,7 +450,6 @@ const loading = ref(false)
const classesLoading = ref(false) const classesLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const restoring = ref(false) const restoring = ref(false)
const materialUploading = ref(false)
const tasks = ref([]) const tasks = ref([])
const classList = ref([]) const classList = ref([])
const selectedTeachingClassId = ref("") const selectedTeachingClassId = ref("")
@ -426,7 +458,6 @@ const dialogMode = ref("view")
const taskForm = reactive(createEmptyTask()) const taskForm = reactive(createEmptyTask())
const currentUserId = computed(() => userStore.userInfo.userId) const currentUserId = computed(() => userStore.userInfo.userId)
const isReadonly = computed(() => dialogMode.value === "view") 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 projects = computed(() => [...new Set(tasks.value.map((task) => task.project).filter(Boolean))])
const projectOptions = computed(() => { const projectOptions = computed(() => {
return projects.value.map((project) => ({ return projects.value.map((project) => ({
@ -452,6 +483,78 @@ const filteredTasks = computed(() => {
}) })
}) })
// 四个资料位(与学生端 TrainingMaterialButton 字段一一对应)
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
}
// 根据 taskKey 反查 trainingTaskKeyByPath,得到每个任务对应的学生端路径
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(() => { onMounted(() => {
setTrainingTaskMainTheme(true) setTrainingTaskMainTheme(true)
loadTeachingClasses() loadTeachingClasses()
@ -564,6 +667,12 @@ function createEmptyTask() {
requirements: "", requirements: "",
materialName: "", materialName: "",
materialUrl: "", materialUrl: "",
trainingDataName: "",
trainingDataUrl: "",
guideManualName: "",
guideManualUrl: "",
aiManualName: "",
aiManualUrl: "",
steps: createBuiltinSteps(), steps: createBuiltinSteps(),
sort: 0, sort: 0,
enabled: true, enabled: true,
@ -572,6 +681,9 @@ function createEmptyTask() {
function normalizeTask(task) { function normalizeTask(task) {
const material = normalizeMaterial(task.materialName, task.materialUrl) 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 { return {
id: task.id, id: task.id,
taskKey: task.taskKey, taskKey: task.taskKey,
@ -582,6 +694,12 @@ function normalizeTask(task) {
requirements: task.requirements || "", requirements: task.requirements || "",
materialName: material.name, materialName: material.name,
materialUrl: material.url, 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())), steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())),
sort: task.sort || 0, sort: task.sort || 0,
enabled: task.enabled !== false, enabled: task.enabled !== false,
@ -693,21 +811,33 @@ function removeStep(index) {
taskForm.steps.splice(index, 1) 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 const rawFile = file?.raw
if (!rawFile) return if (!rawFile) return
const pair = materialFieldPair(slot.key)
if (!pair) return
const data = new FormData() const data = new FormData()
data.append("file", rawFile) data.append("file", rawFile)
materialUploading.value = true slot.uploading = true
try { try {
const res = await uploadTrainingTaskMaterial(data) const res = await uploadTrainingTaskMaterial(data)
taskForm.materialName = res?.originalFilename || rawFile.name || file?.name || taskForm.materialName taskForm[pair.name] = res?.originalFilename || rawFile.name || file?.name || taskForm[pair.name]
taskForm.materialUrl = normalizeMaterialUrl(res?.url || res?.fileName || "") taskForm[pair.url] = normalizeMaterialUrl(res?.url || res?.fileName || "")
proxy?.$modal?.msgSuccess("案例资料上传成功") proxy?.$modal?.msgSuccess?.(`${slot.label}上传成功`)
} catch (error) { } catch (error) {
proxy?.$modal?.msgError?.("案例资料上传失败") proxy?.$modal?.msgError?.(`${slot.label}上传失败`)
} finally { } finally {
materialUploading.value = false slot.uploading = false
} }
} }
@ -758,7 +888,11 @@ async function restoreAllTasksDefaults() {
async function openStudentDemo() { async function openStudentDemo() {
if (!selectedTeachingClass.value) return if (!selectedTeachingClass.value) return
const child = window.open("/student-demo", "_blank") // Vue Router 4 + createWebHashHistory() 只看 hash、不看 URL 路径。
// 直接 /student-demo 会被解释成空 hash → 路由 / → 跳登录或教师首页,
// StudentDemo 组件根本不会挂载,握手 / 兑换 / 切身份这一连串动作
// 不会发生,新窗口只会停留在教师侧。强制带 hash 才能稳定落到 /student-demo。
const child = window.open("/#/student-demo", "_blank")
if (!child) { if (!child) {
proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试") proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试")
return return
@ -811,6 +945,12 @@ function buildPayload() {
steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)), steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)),
materialName: taskForm.materialName, materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl, materialUrl: taskForm.materialUrl,
trainingDataName: taskForm.trainingDataName,
trainingDataUrl: taskForm.trainingDataUrl,
guideManualName: taskForm.guideManualName,
guideManualUrl: taskForm.guideManualUrl,
aiManualName: taskForm.aiManualName,
aiManualUrl: taskForm.aiManualUrl,
sort: taskForm.sort, sort: taskForm.sort,
enabled: taskForm.enabled, 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: 1px 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 6px;
color: var(--edu-primary);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 10px;
background: var(--edu-primary-soft);
border-radius: 4px;
}
}
.card-actions { .card-actions {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -1450,32 +1629,62 @@ function buildPayload() {
} }
} }
.material-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.material-row { .material-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-4); gap: var(--sp-3);
padding: var(--sp-3); padding: var(--sp-3);
margin-bottom: var(--sp-4);
background: #f9fafb; background: #f9fafb;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--r-control); border-radius: var(--r-control);
transition: border-color 0.18s ease, background 0.18s ease;
}
.material-row:hover {
border-color: rgba(59, 130, 246, 0.4);
background: #ffffff;
} }
.material-info { .material-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
min-width: 0;
>svg { .material-icon {
flex-shrink: 0; flex-shrink: 0;
color: var(--edu-primary); 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: 2px;
}
.material-link { .material-link {
overflow: hidden;
color: var(--edu-primary); color: var(--edu-primary);
font-weight: 600; font-weight: 600;
font-size: var(--fs-body); font-size: var(--fs-body);
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
} }
.material-placeholder { .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: 68vh;
overflow: auto;
background: #0b1720;
border-radius: 6px;
}
.image-preview img {
display: block;
max-width: 100%;
height: auto;
object-fit: contain;
}
.pdf-preview {
width: 100%;
height: 68vh;
border: 0;
background: #fff;
}
.preview-empty {
padding: 60px 0;
text-align: center;
color: var(--text-muted);
font-size: var(--fs-body);
}
.step-list { .step-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1583,6 +1836,10 @@ function buildPayload() {
.drawer-identity { .drawer-identity {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.material-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>

Loading…
Cancel
Save