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