Compare commits

...

3 Commits

Author SHA1 Message Date
qinzhenpen 60a394a18c refactor(exam/editor): simplify score table by removing unnecessary score input fields
Removed the score input fields from the score table and adjusted the table header accordingly. This change streamlines the exam editor interface, focusing on essential information while maintaining functionality for score calculations. Additionally, cleaned up related CSS styles to reflect the updated layout.
2 weeks ago
qinzhenpen 4fe83a5c9f Merge remote-tracking branch 'origin/master' into dev-QQq 2 weeks ago
陈沅 e7fd1027fd 实训任务四类资料按需求文档命名:教师端/平台端抽屉支持案例资料、案例视频、实训指导手册、AI排错指导手册上传;学生端未上传的资料按钮直接隐藏 2 weeks ago

@ -5,25 +5,26 @@
<View /> <View />
</el-icon>预览案例资料 </el-icon>预览案例资料
</el-button> </el-button>
<el-button class="material-button" :plain="!caseMaterial?.url" @click="downloadMaterial(caseMaterial, '案例资料')"> <!-- 教师端未上传的资料类型,学生端直接不显示对应按钮 -->
<el-button v-if="caseMaterial" class="material-button" @click="downloadMaterial(caseMaterial, '案例资料')">
<el-icon> <el-icon>
<Download /> <Download />
</el-icon>案例资料 </el-icon>案例资料
</el-button> </el-button>
<el-button class="material-button" :plain="!trainingData?.url" @click="downloadMaterial(trainingData, '实训数据')"> <el-button v-if="trainingData" class="material-button" @click="downloadMaterial(trainingData, '案例视频')">
<el-icon> <el-icon>
<Download /> <Download />
</el-icon>实训数据 </el-icon>案例视频
</el-button> </el-button>
<el-button class="material-button" :plain="!guideManual?.url" @click="downloadMaterial(guideManual, '实训指导手册')"> <el-button v-if="guideManual" class="material-button" @click="downloadMaterial(guideManual, '实训指导手册')">
<el-icon> <el-icon>
<Download /> <Download />
</el-icon>实训指导手册 </el-icon>实训指导手册
</el-button> </el-button>
<el-button class="material-button" :plain="!aiManual?.url" @click="downloadMaterial(aiManual, 'AI辅助指导手册')"> <el-button v-if="aiManual" class="material-button" @click="downloadMaterial(aiManual, 'AI排错指导手册')">
<el-icon> <el-icon>
<Download /> <Download />
</el-icon>AI辅助指导手册 </el-icon>AI排错指导手册
</el-button> </el-button>
</div> </div>
@ -45,13 +46,13 @@ const props = defineProps({
materialUrl: { type: String, default: "" }, materialUrl: { type: String, default: "" },
caseMaterialName: { type: String, default: "" }, caseMaterialName: { type: String, default: "" },
caseMaterialUrl: { type: String, default: "" }, caseMaterialUrl: { type: String, default: "" },
// 实训数据 // 案例视频(内部字段沿用 trainingData 命名)
trainingDataName: { type: String, default: "" }, trainingDataName: { type: String, default: "" },
trainingDataUrl: { type: String, default: "" }, trainingDataUrl: { type: String, default: "" },
// 实训指导手册 // 实训指导手册
guideManualName: { type: String, default: "" }, guideManualName: { type: String, default: "" },
guideManualUrl: { type: String, default: "" }, guideManualUrl: { type: String, default: "" },
// AI 辅助指导手册 // AI 排错指导手册(内部字段沿用 aiManual 命名)
aiManualName: { type: String, default: "" }, aiManualName: { type: String, default: "" },
aiManualUrl: { type: String, default: "" } aiManualUrl: { type: String, default: "" }
}) })

@ -352,36 +352,46 @@
<span class="section-number">03</span> <span class="section-number">03</span>
<div class="section-info"> <div class="section-info">
<h4>资料与步骤</h4> <h4>资料与步骤</h4>
<p>上传案例资料,配置实训步骤</p> <p>上传案例资料、案例视频、实训指导手册与AI排错指导手册,配置实训步骤</p>
</div> </div>
</div> </div>
<div class="material-row"> <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" stroke-width="2"> <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"/> <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"/> <polyline points="14 2 14 8 20 8"/>
</svg> </svg>
<div class="material-text"> <div class="material-text">
<span v-if="hasTaskMaterial" class="material-link">{{ taskForm.materialName }}</span> <span class="material-label">{{ slot.label }}</span>
<span v-else class="material-placeholder">暂未上传案例资料</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> </div>
<el-upload <div class="material-ops">
v-if="!isReadonly" <el-upload
action="#" v-if="!isReadonly"
:auto-upload="false" action="#"
:show-file-list="false" :auto-upload="false"
:on-change="handleMaterialChange" :show-file-list="false"
> :on-change="(file) => handleMaterialChange(slot.key, file)"
<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"> <el-button type="primary" :loading="uploadingField === slot.key" class="upload-btn">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 8 12 3 7 8"/> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<line x1="12" y1="3" x2="12" y2="15"/> <polyline points="17 8 12 3 7 8"/>
</svg> <line x1="12" y1="3" x2="12" y2="15"/>
{{ materialUploading ? '上传中...' : '上传案例资料' }} </svg>
</el-button> {{ uploadingField === slot.key ? '上传中...' : '上传' }}
</el-upload> </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> </div>
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
@ -453,14 +463,20 @@ const activeProject = ref("");
const keyword = ref(""); const keyword = ref("");
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const materialUploading = ref(false); const uploadingField = ref("");
// 四类资料槽位: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 tasks = ref([]);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const dialogMode = ref("view"); const dialogMode = ref("view");
const currentTaskId = ref(""); const currentTaskId = ref("");
const taskForm = reactive(createEmptyTask()); const taskForm = reactive(createEmptyTask());
const isReadonly = computed(() => dialogMode.value === "view"); const isReadonly = computed(() => dialogMode.value === "view");
const hasTaskMaterial = computed(() => isRealMaterial(taskForm.materialName, taskForm.materialUrl));
const enabledCount = computed(() => tasks.value.filter(t => t.enabled).length); const enabledCount = computed(() => tasks.value.filter(t => t.enabled).length);
@ -542,14 +558,31 @@ 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,
}; };
} }
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) { 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,
@ -560,6 +593,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,
@ -664,24 +703,29 @@ async function handleImportChange(file) {
queryTasks(); queryTasks();
} }
async function handleMaterialChange(file) { async function handleMaterialChange(slotKey, file) {
const rawFile = file?.raw; const rawFile = file?.raw;
if (!rawFile) return; if (!rawFile) return;
const data = new FormData(); const data = new FormData();
data.append("file", rawFile); data.append("file", rawFile);
materialUploading.value = true; uploadingField.value = slotKey;
try { try {
const res = await uploadTrainingTaskMaterial(data); const res = await uploadTrainingTaskMaterial(data);
taskForm.materialName = res?.originalFilename || rawFile.name || file?.name || taskForm.materialName; taskForm[`${slotKey}Name`] = res?.originalFilename || rawFile.name || file?.name || taskForm[`${slotKey}Name`];
taskForm.materialUrl = res?.url || res?.fileName || ""; taskForm[`${slotKey}Url`] = res?.url || res?.fileName || "";
proxy?.$modal?.msgSuccess("案例资料上传成功"); proxy?.$modal?.msgSuccess(`${materialLabel(slotKey)}上传成功`);
} catch (error) { } catch (error) {
proxy?.$modal?.msgError?.("案例资料上传失败"); proxy?.$modal?.msgError?.(`${materialLabel(slotKey)}上传失败`);
} finally { } finally {
materialUploading.value = false; uploadingField.value = "";
} }
} }
function clearMaterial(slotKey) {
taskForm[`${slotKey}Name`] = "";
taskForm[`${slotKey}Url`] = "";
}
async function downloadTemplate() { async function downloadTemplate() {
const blob = await downloadTrainingTaskTemplate(); const blob = await downloadTrainingTaskTemplate();
const url = URL.createObjectURL(new Blob([blob])); const url = URL.createObjectURL(new Blob([blob]));
@ -721,6 +765,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,
}; };
@ -1628,15 +1678,32 @@ function buildPayload() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-width: 0;
> svg { > svg {
flex-shrink: 0; flex-shrink: 0;
color: #00aaff; color: #00aaff;
} }
.material-text {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.material-label {
flex-shrink: 0;
color: #e2f3ff;
font-weight: 600;
}
.material-link { .material-link {
overflow: hidden;
color: #00aaff; color: #00aaff;
font-weight: 600; font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
} }
.material-placeholder { .material-placeholder {
@ -1645,6 +1712,18 @@ function buildPayload() {
} }
} }
.material-ops {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
.clear-material-btn {
height: 32px;
font-weight: 600;
}
}
.upload-btn { .upload-btn {
display: flex; display: flex;
align-items: center; align-items: center;

@ -26,24 +26,12 @@
</el-form> </el-form>
<div class="score-table-wrap"> <div class="score-table-wrap">
<table class="score-table"> <table class="score-table">
<thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>每题分值</th><th>小计</th></tr></thead> <thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>小计</th></tr></thead>
<tbody> <tbody>
<tr v-for="item in typeOptions" :key="item.value"> <tr v-for="item in typeOptions" :key="item.value">
<td>{{ item.label }}</td> <td>{{ item.label }}</td>
<td>{{ questionCount(item.value) }}</td> <td>{{ questionCount(item.value) }}</td>
<td>{{ selectedByType(item.value) }}</td> <td>{{ selectedByType(item.value) }}</td>
<td>
<el-input-number
class="type-score-input"
:model-value="scorePerQuestion(item.value)"
:min="0.5"
:precision="1"
:step="0.5"
controls-position="right"
size="small"
@change="setTypeScore(item.value, $event)"
/>
</td>
<td>{{ scoreByType(item.value) }}</td> <td>{{ scoreByType(item.value) }}</td>
</tr> </tr>
</tbody> </tbody>
@ -130,10 +118,6 @@
<div class="q-card-head"> <div class="q-card-head">
<span class="q-card-no">{{ idx + 1 }}</span> <span class="q-card-no">{{ idx + 1 }}</span>
<span class="q-card-type">{{ typeOptions.find(t => t.value === question.type)?.label || question.type }}</span> <span class="q-card-type">{{ typeOptions.find(t => t.value === question.type)?.label || question.type }}</span>
<span v-if="isSelected(question.id)" class="q-score-chip" @click.stop>
<el-input-number :model-value="selectedScore(question.id)" :min="0.5" :precision="1" :step="0.5" controls-position="right" size="small" @change="setScore(question.id, $event)" />
<span>分</span>
</span>
</div> </div>
<h3 class="q-card-stem">{{ question.stem }}</h3> <h3 class="q-card-stem">{{ question.stem }}</h3>
<div v-if="question.options?.length" class="q-options"> <div v-if="question.options?.length" class="q-options">
@ -240,12 +224,11 @@ function isSelected(questionId) { return form.questions.some((item) => item.ques
function selectedScore(questionId) { return form.questions.find((item) => item.questionId === questionId)?.score || 1; } function selectedScore(questionId) { return form.questions.find((item) => item.questionId === questionId)?.score || 1; }
function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); } function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
function removeQuestionFromPreview(question) { toggleQuestion(question, false); } function removeQuestionFromPreview(question) { toggleQuestion(question, false); }
function setScore(questionId, score) { const item = form.questions.find((row) => row.questionId === questionId); if (item) item.score = Number(score); }
function selectedByType(type) { const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); return form.questions.filter((item) => ids.has(item.questionId)).length; } function selectedByType(type) { const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); return form.questions.filter((item) => ids.has(item.questionId)).length; }
function questionCount(type) { return bank.value.filter((item) => item.type === type).length; } function questionCount(type) { return bank.value.filter((item) => item.type === type).length; }
function scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); } function scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); }
function scorePerQuestion(type) { return Number(typeScores[type] || 1); } function scorePerQuestion(type) { return Number(typeScores[type] || 1); }
function setTypeScore(type, score) { const value = Number(score); if (!Number.isFinite(value) || value < 0.5) return; typeScores[type] = value; const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); for (const item of form.questions) if (ids.has(item.questionId)) item.score = value; }
function selectedQuestions(type) { const selected = new Set(form.questions.map((item) => item.questionId)); return bank.value.filter((item) => item.type === type && selected.has(item.id)); } function selectedQuestions(type) { const selected = new Set(form.questions.map((item) => item.questionId)); return bank.value.filter((item) => item.type === type && selected.has(item.id)); }
async function nextStep() { if (activeStep.value === 0) { try { await formRef.value.validate(); const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end <= start) return ElMessage.warning("结束时间必须晚于开始时间"); duration.value = Math.round((end - start) / 60000); } catch { return; } } if (activeStep.value === 1 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); activeStep.value++; } async function nextStep() { if (activeStep.value === 0) { try { await formRef.value.validate(); const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end <= start) return ElMessage.warning("结束时间必须晚于开始时间"); duration.value = Math.round((end - start) / 60000); } catch { return; } } if (activeStep.value === 1 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); activeStep.value++; }
async function save(publish) { if (inProgress.value) { await examApi.updateExam(id.value, form); ElMessage.success("结束时间已更新"); return router.push("/exam/index"); } try { await formRef.value?.validate(); } catch { activeStep.value = 0; return; } if (!form.questions.length) { activeStep.value = 1; return ElMessage.warning("至少选择一道试题"); } const result = isNew.value ? await examApi.createExam(form) : await examApi.updateExam(id.value, form); const examId = result.data.id; if (publish) await examApi.publishExam(examId); ElMessage.success(publish ? "考试已保存并发布" : "考试已保存"); router.push("/exam/index"); } async function save(publish) { if (inProgress.value) { await examApi.updateExam(id.value, form); ElMessage.success("结束时间已更新"); return router.push("/exam/index"); } try { await formRef.value?.validate(); } catch { activeStep.value = 0; return; } if (!form.questions.length) { activeStep.value = 1; return ElMessage.warning("至少选择一道试题"); } const result = isNew.value ? await examApi.createExam(form) : await examApi.updateExam(id.value, form); const examId = result.data.id; if (publish) await examApi.publishExam(examId); ElMessage.success(publish ? "考试已保存并发布" : "考试已保存"); router.push("/exam/index"); }
@ -397,8 +380,6 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
tbody tr:hover td { background: #fafcff; } tbody tr:hover td { background: #fafcff; }
} }
.type-score-input { width: 112px; }
.fixed-step-actions { .fixed-step-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -690,32 +671,6 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
font-weight: 500; font-weight: 500;
} }
.q-score-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
padding: 2px var(--sp-3);
border-radius: var(--r-control);
background: #ffffff;
border: 1px solid var(--edu-primary);
font-size: var(--fs-tag);
color: var(--edu-primary-deep);
span { color: var(--edu-primary-deep); font-weight: 500; }
:deep(.el-input-number) { width: 88px; }
:deep(.el-input-number .el-input__wrapper) {
padding: 2px 6px;
box-shadow: none !important;
background: transparent;
}
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase) {
display: none;
}
}
.q-card-stem { .q-card-stem {
margin: 0 0 var(--sp-2); margin: 0 0 var(--sp-2);
color: var(--text-strong); color: var(--text-strong);

@ -308,10 +308,10 @@
<span class="section-number">03</span> <span class="section-number">03</span>
<div class="section-info"> <div class="section-info">
<h4>资料与步骤</h4> <h4>资料与步骤</h4>
<p>上传案例资料,配置实训步骤</p> <p>上传案例资料、案例视频、实训指导手册与AI排错指导手册,配置实训步骤</p>
</div> </div>
</div> </div>
<div class="material-row"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2"> stroke-width="2">
@ -319,23 +319,29 @@
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
</svg> </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="hasMaterial(slot.key)" class="material-link">{{
<span v-else class="material-placeholder">暂未上传案例资料</span> taskForm[`${slot.key}Name`] }}</span>
<span v-else class="material-placeholder">暂未上传</span>
</div> </div>
</div> </div>
<el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false" <div class="material-ops">
:on-change="handleMaterialChange"> <el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false"
<el-button type="primary" :loading="materialUploading" class="upload-btn"> :on-change="(file) => handleMaterialChange(slot.key, file)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" <el-button type="primary" :loading="uploadingField === slot.key"
stroke-width="2"> class="upload-btn">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /> <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
<polyline points="17 8 12 3 7 8" /> stroke="currentColor" stroke-width="2">
<line x1="12" y1="3" x2="12" y2="15" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
</svg> <polyline points="17 8 12 3 7 8" />
{{ materialUploading ? "上传中..." : "上传案例资料" }} <line x1="12" y1="3" x2="12" y2="15" />
</el-button> </svg>
</el-upload> {{ 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> </div>
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
@ -417,7 +423,14 @@ 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 uploadingField = ref("")
// 四类资料槽位: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 tasks = ref([])
const classList = ref([]) const classList = ref([])
const selectedTeachingClassId = ref("") const selectedTeachingClassId = ref("")
@ -426,7 +439,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) => ({
@ -564,14 +576,31 @@ 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,
} }
} }
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) { 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 +611,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,24 +728,29 @@ function removeStep(index) {
taskForm.steps.splice(index, 1) taskForm.steps.splice(index, 1)
} }
async function handleMaterialChange(file) { async function handleMaterialChange(slotKey, file) {
const rawFile = file?.raw const rawFile = file?.raw
if (!rawFile) return if (!rawFile) return
const data = new FormData() const data = new FormData()
data.append("file", rawFile) data.append("file", rawFile)
materialUploading.value = true uploadingField.value = slotKey
try { try {
const res = await uploadTrainingTaskMaterial(data) const res = await uploadTrainingTaskMaterial(data)
taskForm.materialName = res?.originalFilename || rawFile.name || file?.name || taskForm.materialName taskForm[`${slotKey}Name`] = res?.originalFilename || rawFile.name || file?.name || taskForm[`${slotKey}Name`]
taskForm.materialUrl = normalizeMaterialUrl(res?.url || res?.fileName || "") taskForm[`${slotKey}Url`] = normalizeMaterialUrl(res?.url || res?.fileName || "")
proxy?.$modal?.msgSuccess("案例资料上传成功") proxy?.$modal?.msgSuccess(`${materialLabel(slotKey)}上传成功`)
} catch (error) { } catch (error) {
proxy?.$modal?.msgError?.("案例资料上传失败") proxy?.$modal?.msgError?.(`${materialLabel(slotKey)}上传失败`)
} finally { } finally {
materialUploading.value = false uploadingField.value = ""
} }
} }
function clearMaterial(slotKey) {
taskForm[`${slotKey}Name`] = ""
taskForm[`${slotKey}Url`] = ""
}
async function submitTask() { async function submitTask() {
if (!selectedTeachingClass.value || !taskForm.taskKey) { if (!selectedTeachingClass.value || !taskForm.taskKey) {
proxy?.$modal?.msgError?.("请选择教学班后再保存") proxy?.$modal?.msgError?.("请选择教学班后再保存")
@ -812,6 +852,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,
} }
@ -1467,16 +1513,34 @@ function buildPayload() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
min-width: 0;
>svg { >svg {
flex-shrink: 0; flex-shrink: 0;
color: var(--edu-primary); 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 { .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;
} }
.material-placeholder { .material-placeholder {
@ -1486,6 +1550,19 @@ function buildPayload() {
} }
} }
.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 { .step-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

Loading…
Cancel
Save