feat: add product development process step one

dev-QQq
chenyuan 3 weeks ago
parent d15cfccaba
commit 7ab7c4c930

@ -15,6 +15,13 @@ const newProductSurveyStepOneAnswers = {
"selling-point-11": "有形层",
};
const productDevelopmentProcessStepOneAnswers = {
"1.1": "关键任务", "1.2": "关键任务", "2.1": "关键任务", "2.2": "非关键任务", "2.3": "关键任务",
"3.1": "关键任务", "3.2": "关键任务", "3.3": "非关键任务", "3.4": "关键任务", "3.5": "关键任务", "3.6": "关键任务",
"4.1": "关键任务", "4.2": "关键任务", "4.3": "关键任务", "4.4": "关键任务",
"5.1": "关键任务", "5.2": "关键任务", "5.3": "关键任务", "5.4": "关键任务",
};
function demoResult(taskKey) {
try { return JSON.parse(sessionStorage.getItem(getDemoAnswerKey(taskKey)) || "null"); } catch (error) { return null; }
}
@ -95,6 +102,33 @@ export function checkNewProductSurveyStepOne(items) {
});
}
export function checkProductDevelopmentProcessStepOne(items) {
if (isStudentDemo()) {
const results = Array.isArray(items)
? items.map((item) => {
const correct = productDevelopmentProcessStepOneAnswers[item?.taskNo] === item?.classification;
return { taskNo: item?.taskNo, correct, message: correct ? "分类正确" : "分类有误,请重新选择" };
})
: [];
const correctCount = results.filter((item) => item.correct).length;
return Promise.resolve({
code: 200,
data: {
items: results,
correctCount,
totalCount: results.length,
allCorrect: results.length === Object.keys(productDevelopmentProcessStepOneAnswers).length && correctCount === results.length,
},
});
}
return request({
url: "/api/student-training-answers/product-development-process/step-1/check",
method: "post",
data: { items },
headers: { repeatSubmit: false },
});
}
function validateNewProductSurveyStepTwoDemo(form) {
const value = form || {};
const images = Array.isArray(value.images) ? value.images : [];

@ -205,7 +205,7 @@ export const dynamicRoutes = [
},
{
path: "product-development-process",
component: () => import("@/views/training/GenericTrainingPage.vue"),
component: () => import("@/views/foundation/product-development-process.vue"),
name: "ProductDevelopmentProcess",
meta: { title: "产品开发主要流程", icon: "需求挖掘", affix: true, pageKey: "product-development-process" },
},

@ -0,0 +1,279 @@
<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 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 }]" @click="currentStep = index + 1">
<span class="step-marker">{{ 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" />
</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>
<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 } from "@element-plus/icons-vue";
import { checkProductDevelopmentProcessStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
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 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 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(() => parseArray(taskConfig.value?.steps, DEFAULT_STEPS));
const feedbackByTaskNo = computed(() => Object.fromEntries((checkResult.value?.items || []).map((item) => [item.taskNo, item])));
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: "" }));
}
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);
clearDraft();
}
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 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 buildDraft() { return { step1: buildOutcome(), 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 (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();
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;
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("已清空填写内容");
}
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 { display: grid; grid-template-columns: minmax(720px, 1fr) 340px; gap: 24px; min-height: calc(100vh - 106px); padding: 24px; color: #e8f7ff; background: #06111d; }
.process-core, .process-workbench { min-width: 0; }
.process-core { border: 1px solid rgba(0, 180, 255, .25); background: rgba(8, 18, 28, .6); box-shadow: 0 22px 48px rgba(0,0,0,.22); }
.task-header { padding: 22px 28px 4px; }.task-badge, .section-eyebrow { color: #65dcff; font-weight: 700; }.task-title { margin: 10px 0 0; color: #fff; font-size: 28px; }
.process-workbench { margin: 24px; padding: 22px; border: 1px solid rgba(33, 194, 255, .35); border-radius: 20px; background: rgba(1, 24, 39, .72); }
.workbench-actions, .task-actions { display: flex; align-items: center; gap: 12px; }.workbench-actions { justify-content: flex-end; }.outline-action, .btn-nav { border: 1px solid #2cc9ff; border-radius: 999px; padding: 10px 18px; color: #dff8ff; background: rgba(8, 87, 121, .55); cursor: pointer; }.outline-action { display: inline-flex; align-items: center; gap: 6px; }
.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; }
@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>
Loading…
Cancel
Save