You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/foundation/product-development-process...

641 lines
43 KiB
Vue

<template>
<div class="product-process student-training-shell">
<main class="process-core">
<section class="task-header">
<div class="task-badge"><el-icon><Operation /></el-icon>核心实训 · 产品开发基础</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="process-workbench">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</div>
<nav v-if="trainingSteps.length" class="step-indicator" aria-label="产品开发主要流程步骤">
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
<button
type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
:aria-label="`切换至第 ${index + 1} 步:${step}`"
:aria-current="currentStep === index + 1 ? 'step' : undefined"
@click="currentStep = index + 1"
>
<span class="step-marker" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong class="step-name" :title="step">{{ step }}</strong><span class="step-action">{{ currentStep === index + 1 ? '当前步骤' : '点击切换' }} →</span></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
</template>
</nav>
<section class="process-step-card">
<template v-if="currentStep === 1">
<div class="step-title-row">
<div><p class="section-eyebrow">Step 01</p><h3>{{ trainingSteps[0] }}</h3></div>
<span class="step-chip">关键任务识别</span>
</div>
<p class="process-intro">互联网产品开发在拆解任务之前,团队必须先明确做什么样的产品、开发过程中有哪些卡点。这是后续排期的前提。请判断哪些开发环节需要特别关注:关键任务会影响全项目进度与最终落地效果,非关键任务可在核心任务约束下灵活协调。</p>
<div class="training-table-wrap">
<table class="process-table">
<thead><tr><th>阶段</th><th>任务编号</th><th>具体工作任务</th><th>是否关键任务</th></tr></thead>
<tbody>
<tr v-for="row in processRows" :key="row.taskNo" :class="feedbackClass(row.taskNo)">
<td class="stage-cell">{{ row.stage }}</td>
<td class="task-no-cell">{{ row.taskNo }}</td>
<td class="task-cell">{{ row.task }}</td>
<td class="select-cell">
<select v-model="row.classification" :disabled="checking" @change="clearFeedback(row.taskNo)">
<option value="" disabled>请选择</option>
<option v-for="option in CLASSIFICATION_OPTIONS" :key="option" :value="option">{{ option }}</option>
</select>
<p v-if="feedbackByTaskNo[row.taskNo]" class="classification-feedback">{{ feedbackByTaskNo[row.taskNo].message }}</p>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="checkResult" class="classification-summary" :class="{ 'is-success': checkResult.allCorrect }">本次判题:{{ checkResult.correctCount }} / {{ checkResult.totalCount }} 项分类正确</p>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking" @click="saveAndGoToStepTwo">{{ checking ? '判题中…' : '保存并进入第 2 步' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetTraining"><el-icon><RefreshLeft /></el-icon>清空填写</button>
</div>
</template>
<template v-else-if="currentStep === 2">
<div class="step-title-row">
<div><p class="section-eyebrow">Step 02</p><h3>{{ trainingSteps[1] }}</h3></div>
<div class="step-title-actions">
<button type="button" class="outline-action schedule-export-action" @click="exportScheduleList"><el-icon><Download /></el-icon>导出列表</button>
<span class="step-chip">项目排期练习</span>
</div>
</div>
<p class="process-intro">将开发一款互联网产品的模糊目标,拆解为可执行的具体工作任务。根据案例信息,填写参考工期和项目依赖关系。</p>
<p v-if="!hasCompletedStepOne" class="step-warning">请先完成第 1 步并判题通过;关键任务结果会自动带入本表。</p>
<div class="training-table-wrap">
<table class="process-table schedule-table">
<thead><tr><th>阶段</th><th>任务编号</th><th>具体工作任务</th><th>是否关键任务</th><th>开始时间</th><th>完成时间</th><th>项目依赖关系</th></tr></thead>
<tbody>
<tr v-for="row in processRows" :key="row.taskNo">
<td class="stage-cell">{{ row.stage }}</td>
<td class="task-no-cell">{{ row.taskNo }}</td>
<td class="task-cell">{{ row.task }}</td>
<td class="classification-cell">{{ row.classification || '—' }}</td>
<td><input v-model="row.startDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model="row.endDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model.trim="row.dependency" class="dependency-input" :disabled="!hasCompletedStepOne || checking" placeholder="示例:开始-开始(SS)" maxlength="100" /></td>
</tr>
</tbody>
</table>
</div>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking || !hasCompletedStepOne" @click="saveAndGoToStepThree">{{ checking ? '校验中…' : '保存并进入第 3 步' }}</button>
<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>
<template v-else-if="currentStep === 4">
<div class="step-title-row">
<div><p class="section-eyebrow">Step 04</p><h3>{{ trainingSteps[3] }}</h3></div>
<span class="step-chip">并行协同复盘</span>
</div>
<p v-if="!stepThreePassed" class="step-warning">请先上传并保存第 3 步甘特图,再完成本步复盘。</p>
<section class="process-reflection" :class="{ disabled: !stepThreePassed }">
<label class="reflection-field">
<span>(1)观察甘特图,思考电商运营在产品开发过程中,可以提前介入同步完成哪些工作,以缩短运营周期、提高效率。(如“预热蓄水”“内容种草”等)</span>
<textarea v-model.trim="stepFourForm.parallelWorkAnalysis" :disabled="!stepThreePassed || checking" placeholder="请结合甘特图说明可同步推进的电商运营工作、介入节点及预期效率提升" />
</label>
<div class="reflection-question">(2)在互联网产品开发管理中,你认为哪些环节可以借助AI工具提升效率?请列举至少2个具体应用场景。</div>
<div class="scenario-grid">
<label class="reflection-field"><span>场景1:</span><textarea v-model.trim="stepFourForm.aiScenarioOne" :disabled="!stepThreePassed || checking" placeholder="例如:使用 AI 汇总竞品评价与用户需求,辅助生成PRD初稿" /></label>
<label class="reflection-field"><span>场景2:</span><textarea v-model.trim="stepFourForm.aiScenarioTwo" :disabled="!stepThreePassed || checking" placeholder="例如:使用 AI 生成电商详情页文案、主图脚本或运营素材" /></label>
</div>
</section>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking || !stepThreePassed" @click="submitTraining">提交任务</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetStepFour"><el-icon><RefreshLeft /></el-icon>清空本步</button>
</div>
</template>
<div v-else class="step-placeholder"><p>第 {{ currentStep }} 步内容待配置</p><span>第 1 步答案已保存,可从上方步骤栏返回查看。</span></div>
</section>
</section>
</main>
<TrainingAiSidebar
study-tip="请根据产品开发流程中任务对总进度和最终落地效果的影响,判断其是否为关键任务。"
reference-title="关键任务判断提示"
reference-text="· 关键任务:直接影响产品定义、核心技术验证、量产交付或上市节奏<br />· 非关键任务:可在核心任务约束下进行资源协调,不直接阻塞整体排期"
:progress-items="progressItems"
/>
</div>
</template>
<script setup>
import { Download, Operation, RefreshLeft, Upload } from "@element-plus/icons-vue";
import * as XLSX from "xlsx";
import { checkProductDevelopmentProcessStepOne, checkProductDevelopmentProcessStepTwo, checkProductDevelopmentProcessStepThree, checkProductDevelopmentProcessStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import useUserStore from "@/store/modules/user";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const TASK_KEY = "product-development-process";
const GANTT_PRACTICE_GUIDE_URL = "/file/training-materials/product-development-process/3.甘特图制作实训操作说明.docx";
const GANTT_PRACTICE_GUIDE_NAME = "3.甘特图制作实训操作说明.docx";
const GANTT_TEMPLATE_URL = "/file/training-materials/product-development-process/3.项目进度管理表-项目进度甘特图模版.xlsx";
const GANTT_TEMPLATE_NAME = "3.项目进度管理表-项目进度甘特图模版.xlsx";
const CLASSIFICATION_OPTIONS = ["关键任务", "非关键任务"];
const DEFAULT_STEPS = ["产品开发关键任务识别", "任务依赖关系分析", "开发排期制定", "流程复盘优化"];
const PROCESS_TASKS = [
["需求锚定", "1.1", "竞品调研与用户需求分析"], ["", "1.2", "产品概念确认与核心卖点提炼"],
["产品定义", "2.1", "产品需求文档(PRD)撰写与评审"], ["", "2.2", "外观ID设计"], ["", "2.3", "功能规格确认"],
["开发验证", "3.1", "硬件方案选型与原理图设计"], ["", "3.2", "软件架构及软硬件协同设计"], ["", "3.3", "结构设计评审"], ["", "3.4", "功能手板制作与外观评审"], ["", "3.5", "工程验证测试与设计验证测试"], ["", "3.6", "PVT小批量试产"],
["供应链对接", "4.1", "供应商评估与选定"], ["", "4.2", "模具正式开模与试模修模"], ["", "4.3", "产品认证及质量检测"], ["", "4.4", "BOM成本核算与采购合同签订"],
["上市推广", "5.1", "产品量产及入库"], ["", "5.2", "电商详情页全渠道上线"], ["", "5.3", "正式开售与首销爆发"], ["", "5.4", "首批用户评价监控与差评预警"],
];
const defaultTask = {
title: "产品开发主要流程",
background: "产品开发需要识别影响整体交付、量产和上市节奏的关键环节,并据此安排资源与排期。",
goals: ["理解产品开发的主要阶段与任务", "识别影响项目整体进度的关键任务", "形成后续排期和资源协调的判断依据"],
requirement: "请按任务对整体开发进度和最终落地效果的影响,判断其为关键任务或非关键任务。",
};
const taskConfig = ref(null);
const processRows = ref(createProcessRows());
const currentStep = ref(1);
const checking = ref(false);
const draftReady = ref(false);
const checkResult = ref(null);
const stepOnePassed = ref(false);
const stepTwoPassed = ref(false);
const stepThreePassed = ref(false);
const ganttImage = ref({ url: "", name: "" });
const uploadingGantt = ref(false);
const stepFourForm = ref({ parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" });
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));
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement);
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS));
const feedbackByTaskNo = computed(() => Object.fromEntries((checkResult.value?.items || []).map((item) => [item.taskNo, item])));
const hasCompletedStepOne = computed(() => stepOnePassed.value);
const progressItems = computed(() => [
{ text: `进行中:${trainingSteps.value[Math.max(0, currentStep.value - 1)] || taskTitle.value}`, status: "doing" },
{ text: "完成本步后进入第 2 步", status: "" },
{ text: "可选:导出实训成果", status: "" },
]);
onMounted(async () => {
await loadTaskConfig();
loadDraft();
await loadSavedAnswer();
draftReady.value = true;
});
watch(() => JSON.stringify(buildDraft()), persistDraft);
function createProcessRows() {
return PROCESS_TASKS.map(([stage, taskNo, task]) => ({ stage, taskNo, task, classification: "", startDate: "", endDate: "", dependency: "" }));
}
function parseArray(value, fallback) {
if (Array.isArray(value)) return value.length ? value : fallback;
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback;
} catch (error) {
const list = String(value).split(/[;;|]/).map((item) => item.trim()).filter(Boolean);
return list.length ? list : fallback;
}
}
async function loadTaskConfig() {
try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; }
}
async function loadSavedAnswer() {
try {
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data;
if (answer?.step1Answer) {
restoreOutcome(answer.step1Answer);
stepOnePassed.value = processRows.value.every((row) => CLASSIFICATION_OPTIONS.includes(row.classification));
clearDraft();
}
if (answer?.step2Answer) restoreStepTwoOutcome(answer.step2Answer);
if (answer?.step2Answer) stepTwoPassed.value = true;
if (answer?.step3Answer) {
restoreStepThreeOutcome(answer.step3Answer);
stepThreePassed.value = true;
}
if (answer?.step4Answer) restoreStepFourOutcome(answer.step4Answer);
const savedStep = Number(answer?.currentStep);
if (Number.isInteger(savedStep) && savedStep > 0) currentStep.value = savedStep;
} catch (error) {
// Keep the browser draft available while the answer endpoint is unavailable.
}
}
function restoreOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const savedRows = Array.isArray(parsed?.items) ? parsed.items : [];
const byTaskNo = Object.fromEntries(savedRows.map((item) => [item.taskNo, item]));
processRows.value = createProcessRows().map((row) => ({ ...row, classification: byTaskNo[row.taskNo]?.classification || "" }));
} catch (error) {
processRows.value = createProcessRows();
}
}
function restoreStepTwoOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const byTaskNo = Object.fromEntries((Array.isArray(parsed?.items) ? parsed.items : []).map((item) => [item.taskNo, item]));
processRows.value = processRows.value.map((row) => ({
...row,
startDate: byTaskNo[row.taskNo]?.startDate || "",
endDate: byTaskNo[row.taskNo]?.endDate || "",
dependency: byTaskNo[row.taskNo]?.dependency || "",
}));
} catch (error) {
// Ignore a malformed historical second-step answer.
}
}
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 restoreStepFourOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
stepFourForm.value = {
parallelWorkAnalysis: parsed?.parallelWorkAnalysis || "",
aiScenarioOne: parsed?.aiScenarioOne || "",
aiScenarioTwo: parsed?.aiScenarioTwo || "",
};
} catch (error) {
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
}
}
function getLocalDraftKey() {
const info = userStore.userInfo || {};
return `${TASK_KEY}:draft:${info.userId || info.user_id || info.username || info.userName || "anonymous"}`;
}
function buildOutcome() {
return { title: taskTitle.value, items: processRows.value.map(({ taskNo, classification }) => ({ taskNo, classification })) };
}
function buildStepTwoOutcome() {
return {
title: taskTitle.value,
items: processRows.value.map(({ taskNo, classification, startDate, endDate, dependency }) => ({ taskNo, classification, startDate, endDate, dependency })),
};
}
function getStepTwoValidationMessage() {
for (const row of processRows.value) {
if (!String(row.dependency || "").trim()) return `请填写任务 ${row.taskNo} 的项目依赖关系`;
if (!String(row.startDate || "").trim()) return `请填写任务 ${row.taskNo} 的开始时间`;
if (!String(row.endDate || "").trim()) return `请填写任务 ${row.taskNo} 的完成时间`;
if (row.startDate > row.endDate) return `任务 ${row.taskNo} 的完成时间不能早于开始时间`;
}
return "";
}
function buildStepThreeOutcome() {
return { title: taskTitle.value, ganttImageUrl: ganttImage.value.url, ganttImageName: ganttImage.value.name };
}
function buildStepFourOutcome() {
return { ...stepFourForm.value };
}
function buildDraft() { return { step1: buildOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), currentStep: currentStep.value }; }
function persistDraft() {
if (!draftReady.value) return;
try { localStorage.setItem(getLocalDraftKey(), JSON.stringify(buildDraft())); } catch (error) { /* Browser draft is optional. */ }
}
function loadDraft() {
try {
const raw = localStorage.getItem(getLocalDraftKey());
if (raw) {
const draft = JSON.parse(raw);
restoreOutcome(draft?.step1 || draft);
if (draft?.step2) restoreStepTwoOutcome(draft.step2);
if (draft?.step3) restoreStepThreeOutcome(draft.step3);
if (draft?.step4) restoreStepFourOutcome(draft.step4);
if (Number(draft?.currentStep) > 0) currentStep.value = Number(draft.currentStep);
}
} catch (error) { clearDraft(); }
}
function clearDraft() {
try { localStorage.removeItem(getLocalDraftKey()); } catch (error) { /* Ignore storage failures. */ }
}
function feedbackClass(taskNo) {
const feedback = feedbackByTaskNo.value[taskNo];
return feedback ? (feedback.correct ? "is-correct" : "is-incorrect") : "";
}
function clearFeedback(taskNo) {
if (!feedbackByTaskNo.value[taskNo]) return;
checkResult.value = { ...checkResult.value, items: checkResult.value.items.filter((item) => item.taskNo !== taskNo) };
}
async function saveAndGoToStepTwo() {
if (checking.value) return;
checking.value = true;
try {
const response = await checkProductDevelopmentProcessStepOne(buildOutcome().items);
checkResult.value = response?.data || null;
if (!checkResult.value?.allCorrect) {
proxy?.$modal?.msgError?.("存在分类错误,请根据提示修改后再进入第 2 步");
return;
}
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" });
clearDraft();
stepOnePassed.value = true;
currentStep.value = 2;
proxy?.$modal?.msgSuccess("已保存,已进入第 2 步");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请完成全部任务分类后再判题");
} finally {
checking.value = false;
}
}
async function resetTraining() {
processRows.value = createProcessRows();
currentStep.value = 1;
checkResult.value = null;
stepOnePassed.value = false;
stepTwoPassed.value = false;
stepThreePassed.value = false;
ganttImage.value = { url: "", name: "" };
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
clearDraft();
try {
checking.value = true;
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildOutcome()), currentStep: 1, submitted: false, saveAction: "RESET" });
} catch (error) {
// The local reset remains effective if the backend is temporarily unavailable.
} finally {
checking.value = false;
}
proxy?.$modal?.msgSuccess("已清空填写内容");
}
async function saveAndGoToStepThree() {
if (checking.value || !hasCompletedStepOne.value) return;
const validationMessage = getStepTwoValidationMessage();
if (validationMessage) {
proxy?.$modal?.msgWarning?.(validationMessage);
return;
}
checking.value = true;
try {
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) {
proxy?.$modal?.msgError?.(error?.message || "请完整填写任务工期和项目依赖关系");
} finally {
checking.value = false;
}
}
function resetStepTwo() {
processRows.value = processRows.value.map((row) => ({ ...row, startDate: "", endDate: "", dependency: "" }));
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();
stepThreePassed.value = true;
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 步上传内容");
}
async function submitTraining() {
if (checking.value || !stepThreePassed.value) return;
checking.value = true;
try {
await checkProductDevelopmentProcessStepFour(buildStepFourOutcome());
await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: true, saveAction: "SUBMIT" });
clearDraft();
proxy?.$modal?.msgSuccess("任务提交成功");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请完成并行协同复盘与两个AI应用场景");
} finally {
checking.value = false;
}
}
function resetStepFour() {
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
proxy?.$modal?.msgSuccess("已清空第 4 步填写内容");
}
function previewGanttImage() {
window.open(ganttImage.value.url, "_blank", "noopener,noreferrer");
}
function downloadPracticeGuide() {
downloadBuiltInFile(GANTT_PRACTICE_GUIDE_URL, GANTT_PRACTICE_GUIDE_NAME);
}
function downloadGanttTemplate() {
downloadBuiltInFile(GANTT_TEMPLATE_URL, GANTT_TEMPLATE_NAME);
}
function downloadBuiltInFile(url, filename) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportScheduleList() {
const rows = processRows.value.map((row) => ({
阶段: row.stage,
任务编号: row.taskNo,
具体工作任务: row.task,
是否关键任务: row.classification,
开始时间: row.startDate,
完成时间: row.endDate,
项目依赖关系: row.dependency,
}));
const worksheet = XLSX.utils.json_to_sheet(rows);
worksheet["!cols"] = [
{ wch: 12 }, { wch: 10 }, { wch: 28 }, { wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 22 },
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "WBS排期表");
XLSX.writeFile(workbook, "产品开发WBS任务拆解与排期表.xlsx");
proxy?.$modal?.msgSuccess("列表导出成功");
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildDraft(), null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "产品开发主要流程-实训成果.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess("导出成功");
}
</script>
<style lang="scss" scoped>
.product-process {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
min-height: calc(100vh - 106px);
padding: 24px 24px 32px;
color: #e8f7ff;
background: transparent;
}
.process-core {
min-width: 0;
padding: 20px;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 24px;
background: rgba(8, 18, 28, 0.6);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08);
backdrop-filter: blur(8px);
}
.task-header { margin-bottom: 16px; }
.task-badge { display: inline-flex; align-items: center; gap: 7px; margin-bottom: 12px; padding: 4px 14px; border: 1px solid rgba(0, 170, 255, 0.5); border-radius: 30px; color: #d6f6ff; background: rgba(0, 170, 255, 0.2); font-size: 12px; font-weight: 700; }
.task-title { margin: 0; color: transparent; background: linear-gradient(135deg, #fff, #8bcbff); -webkit-background-clip: text; background-clip: text; font-size: 30px; font-weight: 900; line-height: 1.3; }
.process-workbench { min-width: 0; margin: 0; padding: 14px 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); }
.process-workbench::before { display: none !important; content: none !important; }
.workbench-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 1px solid rgba(0, 180, 255, 0.2); }
.workbench-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; cursor: pointer; }
.outline-action, .workbench-actions :deep(.material-download) { min-height: 40px; padding: 0 18px; border: 1px solid rgba(99, 217, 255, 0.55); border-radius: 999px; color: #e9fbff; background: rgba(17, 126, 178, 0.36); box-shadow: none; font-size: 14px; font-weight: 800; line-height: 1; }
.outline-action:hover, .workbench-actions :deep(.material-download:hover) { border-color: rgba(116, 235, 255, 0.85); color: #fff; background: rgba(18, 146, 204, 0.5); transform: translateY(-1px); }
.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 10px; padding-bottom: 0; border-bottom: 0; }
.step-indicator { position: relative; display: flex; align-items: center; gap: 0; width: calc(100% + 24px); margin: 0 -12px 32px; padding: 18px 22px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, 0.3); border-radius: 18px; background: linear-gradient(90deg, rgba(6, 42, 61, 0.58), rgba(1, 18, 31, 0.42)), rgba(0, 0, 0, 0.3); box-shadow: inset 0 1px 0 rgba(145, 233, 255, 0.08); scrollbar-width: thin; }
.step-tab { display: flex; flex: 1 0 145px; align-items: center; min-width: 0; min-height: 66px; padding: 8px 10px; border: 1px solid rgba(68, 141, 177, 0.35); border-radius: 8px; color: #9ab3d0; background: rgba(3, 27, 43, 0.52); box-shadow: inset 0 1px 0 rgba(135, 221, 255, 0.04); font-family: inherit; cursor: pointer; text-align: left; transition: transform .2s ease, color .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease; }
.step-tab:hover { border-color: rgba(89, 223, 255, 0.82); color: #fff; background: rgba(8, 92, 131, 0.52); box-shadow: 0 7px 18px rgba(0, 0, 0, .24), inset 0 1px 0 rgba(152, 239, 255, .16); transform: translateY(-2px); }.step-tab:hover .step-action { color: #9bf4ff; }.step-tab:focus-visible { outline: 2px solid #72e8ff; outline-offset: 4px; }
.step-tab.active { border-color: rgba(89, 226, 255, .85); color: #fff; background: linear-gradient(115deg, rgba(4, 109, 155, .78), rgba(5, 62, 102, .64)); box-shadow: 0 0 18px rgba(24, 183, 235, .22), inset 0 1px 0 rgba(176, 246, 255, .2); }.step-tab.active .step-marker { border-color: #72e8ff; color: #fff; background: linear-gradient(135deg, #08b7e8, #1375d0); box-shadow: 0 0 0 5px rgba(25, 179, 237, .14), 0 0 18px rgba(46, 208, 255, .58); }.step-tab.complete { color: #dffbff; }.step-tab.complete .step-marker { border-color: rgba(84, 230, 221, .85); color: #062b37; background: #6ef0e1; box-shadow: 0 0 12px rgba(79, 231, 218, .35); }
.step-marker { display: inline-flex; flex: 0 0 38px; align-items: center; justify-content: center; width: 38px; height: 38px; border: 1px solid rgba(101, 176, 208, .6); border-radius: 50%; color: #a6c4d9; background: rgba(3, 25, 40, .8); font-size: 12px; font-weight: 900; letter-spacing: .04em; }.step-copy { display: grid; min-width: 0; gap: 4px; margin-left: 10px; }.step-name { display: -webkit-box; overflow: hidden; color: inherit; font-size: 14px; font-weight: 800; line-height: 1.25; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.step-action { color: #66b6d4; font-size: 10px; font-weight: 800; letter-spacing: .06em; line-height: 1; }.step-connector { position: relative; flex: 0 1 40px; min-width: 18px; height: 2px; margin: 0 8px; background: rgba(98, 169, 201, .34); }.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }.step-tab.complete + .step-connector { background: linear-gradient(90deg, #6ef0e1, #1cb8e9); box-shadow: 0 0 9px rgba(53, 218, 235, .46); }.step-tab.complete + .step-connector::after { border-color: #63e6f1; }
.process-step-card { margin: 0; padding: 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); }.step-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }.step-title-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }.schedule-export-action { min-height: 34px; padding: 0 13px; font-size: 13px; }.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; }.step-chip { padding: 6px 12px; border: 1px solid rgba(94, 219, 255, .54); border-radius: 999px; color: #a6ebff; background: rgba(9, 104, 149, .2); font-size: 13px; font-weight: 800; }
.process-intro { margin: 0 0 18px; color: #d7ecff; font-size: 15px; line-height: 1.8; }.training-table-wrap { overflow-x: auto; border: 1px solid rgba(0, 180, 255, .22); border-radius: 22px; background: rgba(1, 14, 26, .72); }.process-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.process-table th { padding: 12px; border-right: 1px solid rgba(77, 167, 220, .2); color: #eaffff; background: rgba(8, 55, 82, .98); font-size: 14px; font-weight: 900; text-align: center; }.process-table td { padding: 12px; border-top: 1px solid rgba(77, 167, 220, .2); border-right: 1px solid rgba(77, 167, 220, .2); color: #d7f1ff; background: rgba(1, 14, 26, .48); vertical-align: middle; }.process-table th:last-child, .process-table td:last-child { border-right: 0; }.stage-cell { width: 17%; color: #eafaff; font-size: 14px; font-weight: 900; text-align: center; }.task-no-cell { width: 12%; color: #eafaff; font-size: 14px; font-weight: 800; text-align: center; }.task-cell { color: #cfe8f8; font-size: 14px; line-height: 1.7; text-align: center; }.select-cell { width: 23%; }.select-cell select, .schedule-table input { box-sizing: border-box; width: 100%; min-height: 38px; padding: 7px 10px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 10px; outline: none; color: #eef5ff; background: #0f1a24; font: inherit; }.select-cell select:focus, .schedule-table input:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }.classification-feedback { margin: 6px 0 0; color: #ff9b8f; font-size: 12px; line-height: 1.5; }.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 { display: flex; justify-content: center; gap: 52px; margin-top: 36px; }.btn-nav { min-width: 172px; min-height: 64px; padding: 0 28px; border: 1px solid #2c83aa; border-radius: 6px; color: #dff7ff; background: rgba(13, 64, 88, .62); box-shadow: none; font-size: 24px; font-weight: 900; line-height: 1; }.btn-submit { border-color: #2c83aa; background: rgba(13, 64, 88, .62); }.task-actions .btn-nav .el-icon { display: none; }.btn-nav:hover:not(:disabled) { border-color: #46b5e7; color: #fff; background: rgba(18, 85, 116, .78); }.btn-nav:disabled { cursor: not-allowed; opacity: .6; }.step-placeholder { display: grid; place-items: center; min-height: 280px; padding: 28px; border: 1px dashed rgba(101, 207, 239, .42); border-radius: 18px; color: #b8d9ec; background: rgba(1, 14, 26, .42); text-align: center; }.step-placeholder p { margin: 0 0 8px; color: #eaffff; font-size: 20px; font-weight: 800; }
.step-warning { margin: 0 0 16px; padding: 10px 14px; border: 1px solid rgba(255, 173, 93, .62); border-radius: 10px; color: #ffd19f; background: rgba(187, 102, 27, .16); }.schedule-table { min-width: 1020px; }.schedule-table .stage-cell { width: 7%; }.schedule-table .task-no-cell { width: 7%; }.schedule-table .task-cell { width: 18%; }.classification-cell { width: 12%; color: #eafaff; font-weight: 800; text-align: center; }.schedule-table th:nth-child(5), .schedule-table td:nth-child(5), .schedule-table th:nth-child(6), .schedule-table td:nth-child(6) { width: 18%; }.schedule-table .dependency-input { min-width: 0; }.schedule-table .dependency-input::placeholder { color: rgba(168, 220, 233, .68); opacity: 1; }.schedule-table th:nth-child(7), .schedule-table td:nth-child(7) { width: 20%; }.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: 18px; 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: 12px; 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: 12px; 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; border-radius: 0 14px 14px 0; color: #d4edf6; background: rgba(12, 75, 104, .22); }.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; }
.process-reflection { display: grid; gap: 28px; }.reflection-field { display: grid; gap: 12px; color: #d8edf4; font-size: 16px; line-height: 1.7; }.reflection-field span { color: #f1fbff; font-weight: 600; }.reflection-field textarea { min-height: 190px; resize: vertical; padding: 14px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 14px; color: #eef5ff; background: #0f1a24; font: inherit; line-height: 1.7; }.reflection-field textarea:focus { outline: 0; border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }.reflection-field textarea:disabled { cursor: not-allowed; opacity: .55; }.reflection-question { color: #f1fbff; font-size: 16px; font-weight: 600; line-height: 1.8; }.scenario-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; }.scenario-grid .reflection-field textarea { min-height: 180px; }.process-reflection.disabled { opacity: .72; }
@media (max-width: 1100px) { .product-process { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .product-process { padding: 14px 14px 18px; }.process-core { padding: 18px; border-radius: 24px; }.workbench-head, .workbench-actions, .task-actions, .step-indicator, .step-title-row { flex-direction: column; align-items: stretch; }.workbench-actions :deep(.material-download), .outline-action { width: 100%; }.step-indicator { width: 100%; margin: 0 0 32px; padding: 16px; overflow-x: visible; border-radius: 20px; }.step-tab { flex: 0 0 auto; width: 100%; min-width: 0; }.step-connector { flex: 0 0 20px; align-self: flex-start; width: 2px; min-width: 0; height: 20px; margin: 4px 0 4px 18px; }.step-connector::after { top: auto; right: auto; bottom: -1px; left: 50%; transform: translateX(-50%) rotate(135deg); }.step-chip { display: none; }.scenario-grid { grid-template-columns: 1fr; gap: 18px; }.task-actions { gap: 14px; }.btn-nav { width: 100%; } }
</style>