|
|
|
|
@ -87,6 +87,38 @@
|
|
|
|
|
<button type="button" class="btn-nav" :disabled="checking" @click="resetStepTwo"><el-icon><RefreshLeft /></el-icon>清空本步</button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else-if="currentStep === 3">
|
|
|
|
|
<div class="step-title-row">
|
|
|
|
|
<div><p class="section-eyebrow">Step 03</p><h3>{{ trainingSteps[2] }}</h3></div>
|
|
|
|
|
<span class="step-chip">甘特图绘制</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="process-intro">根据上表数据,使用工具绘制甘特图,并上传。</p>
|
|
|
|
|
<p v-if="!stepTwoPassed" class="step-warning">请先保存第 2 步项目排期,再上传甘特图。</p>
|
|
|
|
|
<section class="gantt-upload-panel">
|
|
|
|
|
<label class="gantt-upload-trigger" :class="{ disabled: !stepTwoPassed || uploadingGantt }">
|
|
|
|
|
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" :disabled="!stepTwoPassed || uploadingGantt" @change="uploadGanttImage" />
|
|
|
|
|
<el-icon><Upload /></el-icon>
|
|
|
|
|
<strong>{{ uploadingGantt ? '上传中…' : ganttImage.url ? '替换甘特图' : '上传甘特图' }}</strong>
|
|
|
|
|
<span>支持 PNG、JPG、JPEG、WEBP、GIF</span>
|
|
|
|
|
</label>
|
|
|
|
|
<figure v-if="ganttImage.url" class="gantt-preview">
|
|
|
|
|
<img :src="ganttImage.url" alt="已上传的甘特图" @click="previewGanttImage" />
|
|
|
|
|
<figcaption>{{ ganttImage.name }}</figcaption>
|
|
|
|
|
</figure>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="gantt-guide">
|
|
|
|
|
<h4>推荐工具如下,自行选择作图工具:</h4>
|
|
|
|
|
<ol>
|
|
|
|
|
<li>Excel/WPS表格,利用堆积条形图制作,可参考《实训操作说明》完成。<button type="button" @click="downloadPracticeGuide">下载实训操作说明</button></li>
|
|
|
|
|
<li>下载Excel模板,填写关键信息,完成甘特图绘制。<button type="button" @click="downloadGanttTemplate">下载甘特图绘制模板</button></li>
|
|
|
|
|
<li>导出步骤二信息表,上传到通用AI绘图模型,通过大模型绘制甘特图。参考提示词:请根据产品WBS任务拆解与排期表生成甘特图用于后续项目管理。</li>
|
|
|
|
|
</ol>
|
|
|
|
|
</section>
|
|
|
|
|
<div class="task-actions">
|
|
|
|
|
<button type="button" class="btn-nav btn-submit" :disabled="uploadingGantt || !stepTwoPassed" @click="saveAndGoToStepFour">保存并进入第 4 步</button>
|
|
|
|
|
<button type="button" class="btn-nav" :disabled="uploadingGantt" @click="resetStepThree"><el-icon><RefreshLeft /></el-icon>清空本步</button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<div v-else class="step-placeholder"><p>第 {{ currentStep }} 步内容待配置</p><span>第 1 步答案已保存,可从上方步骤栏返回查看。</span></div>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
@ -102,8 +134,9 @@
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { Download, Operation, RefreshLeft } from "@element-plus/icons-vue";
|
|
|
|
|
import { checkProductDevelopmentProcessStepOne, checkProductDevelopmentProcessStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { Download, Operation, RefreshLeft, Upload } from "@element-plus/icons-vue";
|
|
|
|
|
import * as XLSX from "xlsx";
|
|
|
|
|
import { checkProductDevelopmentProcessStepOne, checkProductDevelopmentProcessStepTwo, checkProductDevelopmentProcessStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
import useUserStore from "@/store/modules/user";
|
|
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
|
|
@ -136,6 +169,9 @@ const checking = ref(false);
|
|
|
|
|
const draftReady = ref(false);
|
|
|
|
|
const checkResult = ref(null);
|
|
|
|
|
const stepOnePassed = ref(false);
|
|
|
|
|
const stepTwoPassed = ref(false);
|
|
|
|
|
const ganttImage = ref({ url: "", name: "" });
|
|
|
|
|
const uploadingGantt = ref(false);
|
|
|
|
|
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title);
|
|
|
|
|
const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background);
|
|
|
|
|
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals));
|
|
|
|
|
@ -187,6 +223,8 @@ async function loadSavedAnswer() {
|
|
|
|
|
clearDraft();
|
|
|
|
|
}
|
|
|
|
|
if (answer?.step2Answer) restoreStepTwoOutcome(answer.step2Answer);
|
|
|
|
|
if (answer?.step2Answer) stepTwoPassed.value = true;
|
|
|
|
|
if (answer?.step3Answer) restoreStepThreeOutcome(answer.step3Answer);
|
|
|
|
|
const savedStep = Number(answer?.currentStep);
|
|
|
|
|
if (Number.isInteger(savedStep) && savedStep > 0) currentStep.value = savedStep;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@ -220,6 +258,15 @@ function restoreStepTwoOutcome(value) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreStepThreeOutcome(value) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
|
|
|
ganttImage.value = { url: parsed?.ganttImageUrl || "", name: parsed?.ganttImageName || "" };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
ganttImage.value = { url: "", name: "" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLocalDraftKey() {
|
|
|
|
|
const info = userStore.userInfo || {};
|
|
|
|
|
return `${TASK_KEY}:draft:${info.userId || info.user_id || info.username || info.userName || "anonymous"}`;
|
|
|
|
|
@ -236,7 +283,11 @@ function buildStepTwoOutcome() {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDraft() { return { step1: buildOutcome(), step2: buildStepTwoOutcome(), currentStep: currentStep.value }; }
|
|
|
|
|
function buildStepThreeOutcome() {
|
|
|
|
|
return { title: taskTitle.value, ganttImageUrl: ganttImage.value.url, ganttImageName: ganttImage.value.name };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDraft() { return { step1: buildOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), currentStep: currentStep.value }; }
|
|
|
|
|
|
|
|
|
|
function persistDraft() {
|
|
|
|
|
if (!draftReady.value) return;
|
|
|
|
|
@ -250,6 +301,7 @@ function loadDraft() {
|
|
|
|
|
const draft = JSON.parse(raw);
|
|
|
|
|
restoreOutcome(draft?.step1 || draft);
|
|
|
|
|
if (draft?.step2) restoreStepTwoOutcome(draft.step2);
|
|
|
|
|
if (draft?.step3) restoreStepThreeOutcome(draft.step3);
|
|
|
|
|
if (Number(draft?.currentStep) > 0) currentStep.value = Number(draft.currentStep);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) { clearDraft(); }
|
|
|
|
|
@ -296,6 +348,8 @@ async function resetTraining() {
|
|
|
|
|
currentStep.value = 1;
|
|
|
|
|
checkResult.value = null;
|
|
|
|
|
stepOnePassed.value = false;
|
|
|
|
|
stepTwoPassed.value = false;
|
|
|
|
|
ganttImage.value = { url: "", name: "" };
|
|
|
|
|
clearDraft();
|
|
|
|
|
try {
|
|
|
|
|
checking.value = true;
|
|
|
|
|
@ -315,6 +369,7 @@ async function saveAndGoToStepThree() {
|
|
|
|
|
await checkProductDevelopmentProcessStepTwo(buildStepTwoOutcome().items);
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" });
|
|
|
|
|
clearDraft();
|
|
|
|
|
stepTwoPassed.value = true;
|
|
|
|
|
currentStep.value = 3;
|
|
|
|
|
proxy?.$modal?.msgSuccess("已保存,已进入第 3 步");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@ -329,6 +384,98 @@ function resetStepTwo() {
|
|
|
|
|
proxy?.$modal?.msgSuccess("已清空第 2 步填写内容");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadGanttImage(event) {
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (!file || !stepTwoPassed.value) return;
|
|
|
|
|
if (!/^image\/(png|jpeg|webp|gif)$/i.test(file.type)) {
|
|
|
|
|
proxy?.$modal?.msgError?.("请上传 PNG、JPG、JPEG、WEBP 或 GIF 格式的甘特图");
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
uploadingGantt.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("file", file);
|
|
|
|
|
const response = await uploadStudentTrainingFile(formData);
|
|
|
|
|
ganttImage.value = {
|
|
|
|
|
url: response?.url || response?.fileName || "",
|
|
|
|
|
name: response?.originalFilename || file.name,
|
|
|
|
|
};
|
|
|
|
|
if (!ganttImage.value.url) throw new Error("上传结果缺少文件地址");
|
|
|
|
|
proxy?.$modal?.msgSuccess("甘特图上传成功");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
ganttImage.value = { url: "", name: "" };
|
|
|
|
|
proxy?.$modal?.msgError?.(error?.message || "甘特图上传失败");
|
|
|
|
|
} finally {
|
|
|
|
|
uploadingGantt.value = false;
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveAndGoToStepFour() {
|
|
|
|
|
if (uploadingGantt.value || !stepTwoPassed.value) return;
|
|
|
|
|
checking.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await checkProductDevelopmentProcessStepThree(buildStepThreeOutcome());
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" });
|
|
|
|
|
clearDraft();
|
|
|
|
|
currentStep.value = 4;
|
|
|
|
|
proxy?.$modal?.msgSuccess("已保存,已进入第 4 步");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
proxy?.$modal?.msgError?.(error?.message || "请先上传甘特图");
|
|
|
|
|
} finally {
|
|
|
|
|
checking.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetStepThree() {
|
|
|
|
|
ganttImage.value = { url: "", name: "" };
|
|
|
|
|
proxy?.$modal?.msgSuccess("已清空第 3 步上传内容");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function previewGanttImage() {
|
|
|
|
|
window.open(ganttImage.value.url, "_blank", "noopener,noreferrer");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadPracticeGuide() {
|
|
|
|
|
const content = [
|
|
|
|
|
"产品开发甘特图实训操作说明",
|
|
|
|
|
"1. 使用第 2 步填写的任务编号、开始时间、完成时间和项目依赖关系建立任务表。",
|
|
|
|
|
"2. 在 Excel 或 WPS 中按日期轴创建堆积条形图;开始时间作为不可见基线,任务工期作为可见条形。",
|
|
|
|
|
"3. 为关键任务使用醒目颜色,并标注任务名称、开始及完成时间。",
|
|
|
|
|
"4. 导出或截图为 PNG/JPG 后上传至本步骤。",
|
|
|
|
|
].join("\n");
|
|
|
|
|
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), "产品开发甘特图-实训操作说明.txt");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadGanttTemplate() {
|
|
|
|
|
const rows = processRows.value.map((row) => ({
|
|
|
|
|
阶段: row.stage,
|
|
|
|
|
任务编号: row.taskNo,
|
|
|
|
|
具体工作任务: row.task,
|
|
|
|
|
是否关键任务: row.classification,
|
|
|
|
|
开始时间: row.startDate,
|
|
|
|
|
完成时间: row.endDate,
|
|
|
|
|
项目依赖关系: row.dependency,
|
|
|
|
|
}));
|
|
|
|
|
const sheet = XLSX.utils.json_to_sheet(rows);
|
|
|
|
|
sheet["!cols"] = [{ wch: 16 }, { wch: 12 }, { wch: 32 }, { wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 24 }];
|
|
|
|
|
const workbook = XLSX.utils.book_new();
|
|
|
|
|
XLSX.utils.book_append_sheet(workbook, sheet, "甘特图任务表");
|
|
|
|
|
XLSX.writeFile(workbook, "产品开发甘特图绘制模板.xlsx");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadBlob(blob, filename) {
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.download = filename;
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
link.click();
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exportOutcome() {
|
|
|
|
|
const blob = new Blob([JSON.stringify(buildDraft(), null, 2)], { type: "application/json;charset=utf-8" });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
@ -353,6 +500,7 @@ function exportOutcome() {
|
|
|
|
|
.step-indicator { display: flex; align-items: center; gap: 12px; margin: 28px 0; padding: 12px; border: 1px solid rgba(30, 190, 255, .28); border-radius: 22px; }.step-tab { display: flex; align-items: center; flex: 1; gap: 10px; min-width: 0; padding: 10px; border: 0; border-radius: 16px; color: #96c6d8; background: transparent; text-align: left; cursor: pointer; }.step-tab.active { color: #fff; background: linear-gradient(110deg, #148fc6, #0879aa); }.step-marker { display: grid; place-items: center; flex: 0 0 42px; width: 42px; height: 42px; border: 1px solid #4ed7ff; border-radius: 50%; font-weight: 800; }.step-copy { display: grid; min-width: 0; gap: 4px; }.step-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.step-action { font-size: 12px; color: #5ec9ed; }.step-connector { flex: 0 0 25px; height: 2px; background: rgba(83, 204, 255, .4); }
|
|
|
|
|
.process-step-card { padding: 24px; border: 1px solid rgba(31, 185, 249, .32); border-radius: 18px; background: rgba(1, 21, 34, .65); }.step-title-row { display: flex; justify-content: space-between; align-items: flex-start; }.step-title-row h3 { margin: 5px 0 0; color: #fff; font-size: 24px; }.step-chip { border: 1px solid #38caff; border-radius: 999px; padding: 7px 12px; color: #9aeaff; }.process-intro { margin: 24px 0; line-height: 1.8; color: #c5e4ef; }.training-table-wrap { overflow-x: auto; }.process-table { width: 100%; min-width: 740px; border-collapse: collapse; color: #dff5ff; }.process-table th { padding: 14px; color: #fff; background: #079ed9; font-size: 16px; }.process-table td, .process-table th { border: 1px solid rgba(139, 213, 241, .45); text-align: center; }.process-table td { padding: 9px 12px; }.stage-cell { width: 16%; font-weight: 700; }.task-no-cell { width: 15%; }.task-cell { width: 37%; }.select-cell { width: 32%; }.select-cell select { width: 170px; max-width: 100%; padding: 6px 10px; border: 1px solid #6ea9bd; color: #132c36; background: #fff; font-size: 15px; }.classification-feedback { margin: 6px 0 0; color: #ff9b8f; font-size: 12px; }.is-correct { background: rgba(55, 196, 147, .08); }.is-incorrect { background: rgba(255, 91, 82, .1); }.classification-summary { margin: 16px 0 0; color: #ffbc86; }.classification-summary.is-success { color: #6ee8b1; }.task-actions { justify-content: center; margin-top: 26px; }.btn-submit { border-color: #36d9ff; background: linear-gradient(110deg, #0eaae4, #0572ac); font-weight: 700; }.btn-nav:disabled { opacity: .6; cursor: not-allowed; }.step-placeholder { padding: 90px 20px; text-align: center; color: #a8cfdd; }.step-placeholder p { color: #fff; font-size: 20px; }
|
|
|
|
|
.step-warning { margin: 0 0 16px; padding: 10px 14px; border: 1px solid rgba(255, 173, 93, .62); border-radius: 8px; color: #ffd19f; background: rgba(187, 102, 27, .16); }.schedule-table { min-width: 1180px; }.schedule-table .stage-cell { width: 13%; }.schedule-table .task-no-cell { width: 9%; }.schedule-table .task-cell { width: 20%; }.classification-cell { width: 13%; font-weight: 700; }.schedule-table input { box-sizing: border-box; width: 150px; max-width: 100%; padding: 7px 8px; border: 1px solid #6ea9bd; color: #132c36; background: #fff; }.schedule-table .dependency-input { width: 190px; }.schedule-table input:disabled { cursor: not-allowed; opacity: .6; }
|
|
|
|
|
.gantt-upload-panel { display: grid; justify-items: center; gap: 22px; min-height: 320px; padding: 32px 16px; border: 1px dashed rgba(71, 204, 255, .55); border-radius: 14px; background: radial-gradient(circle at center, rgba(15, 119, 163, .16), transparent 55%); }.gantt-upload-trigger { display: grid; place-items: center; align-content: center; gap: 9px; width: 280px; height: 138px; border-radius: 10px; color: #fff; background: linear-gradient(135deg, #0ba6e0, #0877ad); cursor: pointer; box-shadow: 0 14px 30px rgba(5, 126, 180, .24); }.gantt-upload-trigger input { position: absolute; width: 1px; height: 1px; opacity: 0; }.gantt-upload-trigger .el-icon { font-size: 26px; }.gantt-upload-trigger span { color: #cbefff; font-size: 12px; }.gantt-upload-trigger.disabled { cursor: not-allowed; opacity: .55; }.gantt-preview { width: min(100%, 760px); margin: 0; text-align: center; }.gantt-preview img { display: block; width: 100%; max-height: 410px; object-fit: contain; border: 1px solid rgba(84, 211, 255, .5); border-radius: 10px; background: #fff; cursor: zoom-in; }.gantt-preview figcaption { margin-top: 8px; color: #a8dce9; font-size: 13px; }.gantt-guide { margin-top: 26px; padding: 18px 22px; border-left: 3px solid #23c4ff; background: rgba(12, 75, 104, .22); color: #d4edf6; }.gantt-guide h4 { margin: 0 0 14px; color: #fff; }.gantt-guide ol { margin: 0; padding-left: 20px; }.gantt-guide li { margin: 11px 0; line-height: 1.7; }.gantt-guide button { margin-left: 12px; border: 0; border-radius: 6px; padding: 5px 10px; color: #fff; background: #109ed4; cursor: pointer; }
|
|
|
|
|
@media (max-width: 1280px) { .product-process { grid-template-columns: 1fr; }.step-indicator { overflow-x: auto; }.step-tab { min-width: 190px; } }
|
|
|
|
|
@media (max-width: 720px) { .product-process { padding: 12px; }.process-workbench { margin: 12px; padding: 14px; }.task-header { padding: 18px; }.workbench-actions { justify-content: flex-start; flex-wrap: wrap; }.step-title-row { gap: 12px; }.step-chip { display: none; } }
|
|
|
|
|
</style>
|
|
|
|
|
|