实训任务四类资料按需求文档命名:教师端/平台端抽屉支持案例资料、案例视频、实训指导手册、AI排错指导手册上传;学生端未上传的资料按钮直接隐藏

fix/exam-question-access-material-excel-delete-draft
陈沅 2 weeks ago
parent f120701ebe
commit e7fd1027fd

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

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