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

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

@ -5,25 +5,26 @@
<View />
</el-icon>预览案例资料
</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>
<Download />
</el-icon>案例资料
</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>
<Download />
</el-icon>实训数据
</el-icon>案例视频
</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>
<Download />
</el-icon>实训指导手册
</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>
<Download />
</el-icon>AI辅助指导手册
</el-icon>AI排错指导手册
</el-button>
</div>
@ -45,13 +46,13 @@ const props = defineProps({
materialUrl: { type: String, default: "" },
caseMaterialName: { type: String, default: "" },
caseMaterialUrl: { type: String, default: "" },
// 实训数据
// 案例视频(内部字段沿用 trainingData 命名)
trainingDataName: { type: String, default: "" },
trainingDataUrl: { type: String, default: "" },
// 实训指导手册
guideManualName: { type: String, default: "" },
guideManualUrl: { type: String, default: "" },
// AI 辅助指导手册
// AI 排错指导手册(内部字段沿用 aiManual 命名)
aiManualName: { type: String, default: "" },
aiManualUrl: { type: String, default: "" }
})

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

@ -308,10 +308,10 @@
<span class="section-number">03</span>
<div class="section-info">
<h4>资料与步骤</h4>
<p>上传案例资料,配置实训步骤</p>
<p>上传案例资料、案例视频、实训指导手册与AI排错指导手册,配置实训步骤</p>
</div>
</div>
<div class="material-row">
<div v-for="slot in materialSlots" :key="slot.key" class="material-row">
<div class="material-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
@ -319,23 +319,29 @@
<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>
<span class="material-label">{{ slot.label }}</span>
<span v-if="hasMaterial(slot.key)" class="material-link">{{
taskForm[`${slot.key}Name`] }}</span>
<span v-else class="material-placeholder">暂未上传</span>
</div>
</div>
<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 ? "上传中..." : "上传案例资料" }}
</el-button>
</el-upload>
<div class="material-ops">
<el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false"
:on-change="(file) => handleMaterialChange(slot.key, file)">
<el-button type="primary" :loading="uploadingField === slot.key"
class="upload-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{{ uploadingField === slot.key ? "上传中..." : "上传" }}
</el-button>
</el-upload>
<el-button v-if="!isReadonly && hasMaterial(slot.key)" type="danger" plain
class="clear-material-btn" @click="clearMaterial(slot.key)">清除</el-button>
</div>
</div>
<el-form-item label="步骤名称:">
@ -417,7 +423,14 @@ const loading = ref(false)
const classesLoading = ref(false)
const saving = 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 classList = ref([])
const selectedTeachingClassId = ref("")
@ -426,7 +439,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) => ({
@ -564,14 +576,31 @@ function createEmptyTask() {
requirements: "",
materialName: "",
materialUrl: "",
trainingDataName: "",
trainingDataUrl: "",
guideManualName: "",
guideManualUrl: "",
aiManualName: "",
aiManualUrl: "",
steps: createBuiltinSteps(),
sort: 0,
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) {
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 +611,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,24 +728,29 @@ function removeStep(index) {
taskForm.steps.splice(index, 1)
}
async function handleMaterialChange(file) {
async function handleMaterialChange(slotKey, file) {
const rawFile = file?.raw
if (!rawFile) return
const data = new FormData()
data.append("file", rawFile)
materialUploading.value = true
uploadingField.value = slotKey
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[`${slotKey}Name`] = res?.originalFilename || rawFile.name || file?.name || taskForm[`${slotKey}Name`]
taskForm[`${slotKey}Url`] = normalizeMaterialUrl(res?.url || res?.fileName || "")
proxy?.$modal?.msgSuccess(`${materialLabel(slotKey)}上传成功`)
} catch (error) {
proxy?.$modal?.msgError?.("案例资料上传失败")
proxy?.$modal?.msgError?.(`${materialLabel(slotKey)}上传失败`)
} finally {
materialUploading.value = false
uploadingField.value = ""
}
}
function clearMaterial(slotKey) {
taskForm[`${slotKey}Name`] = ""
taskForm[`${slotKey}Url`] = ""
}
async function submitTask() {
if (!selectedTeachingClass.value || !taskForm.taskKey) {
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)),
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,
}
@ -1467,16 +1513,34 @@ function buildPayload() {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
>svg {
flex-shrink: 0;
color: var(--edu-primary);
}
.material-text {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.material-label {
flex-shrink: 0;
color: var(--text-strong);
font-size: var(--fs-body);
font-weight: 600;
}
.material-link {
overflow: hidden;
color: var(--edu-primary);
font-weight: 600;
font-size: var(--fs-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.material-placeholder {
@ -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 {
display: flex;
flex-direction: column;

Loading…
Cancel
Save