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/demand/industry-demand-analysis.vue

855 lines
40 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="industry-pest-page student-training-shell">
<main class="industry-core">
<section class="task-header">
<div class="task-head-main">
<span class="task-badge"><el-icon><DataAnalysis /></el-icon> 核心实训 · 市场洞察与需求分析</span>
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
<TrainingTaskToolbar class="industry-demand-toolbar" :task-key="TASK_KEY" :on-submit="submitTraining" :on-reset="resetTraining" :on-export="exportOutcome" />
</section>
<section class="industry-workbench">
<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="switchStep(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="industry-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">PEST 分类练习</span></div>
<p class="step-desc">请根据案例文档中的案例信息进行 PEST 分析,进行上述各项内容归类,把文档中相应内容填写到以下四类中。</p>
<TrainingStepOneChoiceQuestions :questions="taskConfig?.stepOneQuestions" v-model="stepOneChoiceAnswers" />
<div class="pest-table-wrap">
<table class="pest-table"><thead><tr><th>序号</th><th>PEST类型</th><th>信息资讯</th></tr></thead>
<tbody><tr v-for="(row, index) in pestRows" :key="row.key"><td>{{ index + 1 }}</td><td class="type-cell">{{ row.label }}</td><td><el-input v-model.trim="form[row.key]" type="textarea" :autosize="{ minRows: 2 }" :placeholder="row.placeholder" /></td></tr></tbody></table></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><span class="step-chip">资料补充</span></div>
<div class="research-guide">
<p>请自行收集资料,补充 PEST 完善,每个维度至少补充 1 条。信息收集可参考以下方法:</p>
<ol>
<li>自行通过互联网平台搜索、电商平台(京东/天猫搜索“智能健身镜”)、行业报告网站(艾瑞咨询、前瞻产业研究院等)、新闻资讯(百度/头条搜索健身行业关键词)。</li>
<li>使用 AI 大模型智能搜索功能进行信息收集,参考提示词见实训任务案例文档。</li>
</ol>
<p>请把补充的 PEST 信息资讯填入下表:</p>
</div>
<p v-if="!stepOnePassed" class="step-warning">请先完成并保存第 1 步的 PEST 信息归类,再填写本步骤。</p>
<div class="pest-table-wrap">
<table class="pest-table"><thead><tr><th>序号</th><th>PEST类型</th><th>补充信息资讯</th></tr></thead>
<tbody><tr v-for="(row, index) in pestRows" :key="row.key"><td>{{ index + 1 }}</td><td class="type-cell">{{ row.label }}</td><td><el-input v-model.trim="supplementForm[row.key]" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepOnePassed" :placeholder="row.placeholder" /></td></tr></tbody></table></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="step-desc">基于上述 PEST 信息归类与补充的信息,从每个 PEST 维度分别提炼该产品面临的核心机遇与潜在挑战,并给出应对思路。每个单元格至少填写 1 条,可按换行分条。</p>
<p v-if="!stepTwoPassed" class="step-warning">请先完成并保存第 2 步的 PEST 补充资讯,再填写本步骤。</p>
<div class="analysis-table-wrap">
<table class="analysis-table"><thead><tr><th>PEST维度</th><th>核心机遇(至少1条)</th><th>潜在挑战(至少1条)</th><th>应对思路建议</th></tr></thead>
<tbody><tr v-for="row in pestAnalysisRows" :key="row.key"><td class="type-cell">{{ row.label }}</td><td><el-input v-model.trim="analysisForm[row.key].opportunities" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepTwoPassed" placeholder="例如:政策、需求或技术带来的增长机会" /></td><td><el-input v-model.trim="analysisForm[row.key].challenges" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepTwoPassed" placeholder="例如:合规、成本、竞争或使用门槛" /></td><td><el-input v-model.trim="analysisForm[row.key].strategies" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepTwoPassed" placeholder="例如:针对挑战给出的产品、运营或合规方案" /></td></tr></tbody></table></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 class="step-desc">基于前述 PEST 分析,完成行业环境评估、市场进入判断与风险总结。</p>
<p v-if="!stepThreePassed" class="step-warning">请先完成并保存第 3 步的机会与挑战分析,再填写本步骤。</p>
<div class="conclusion-form" :class="{ 'is-disabled': !stepThreePassed }">
<section class="conclusion-section"><h4>(1)总体 PEST 环境评估</h4><p>概括当前宏观环境对该产品的总体友好程度,从以下五个等级中选择一个:</p><div class="option-row"><el-radio-group v-model="conclusionForm.overallAssessment" :disabled="!stepThreePassed" class="option-row-group"><el-radio v-for="item in assessmentOptions" :key="item" :label="item" class="option-card">{{ item }}</el-radio></el-radio-group></div></section>
<section class="conclusion-section"><h4>(2)市场进入结论</h4><p>从以下三个结论中选择一个,并说明理由:</p><div class="option-row decision-options"><el-radio-group v-model="conclusionForm.marketDecision" :disabled="!stepThreePassed" class="option-row-group"><el-radio v-for="item in decisionOptions" :key="item" :label="item" class="option-card">{{ item }}</el-radio></el-radio-group></div><el-input v-model.trim="conclusionForm.decisionReason" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepThreePassed" placeholder="请说明市场进入结论的理由" /></section>
<section class="conclusion-section"><h4>(3)市场进入需要关注的风险点</h4><p>请指出 2 个以上的风险点。</p><div class="risk-grid"><label>核心风险点 1:<el-input v-model.trim="conclusionForm.riskOne" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label>核心风险点 2:<el-input v-model.trim="conclusionForm.riskTwo" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label>其他风险点:<el-input v-model.trim="conclusionForm.otherRisks" type="textarea" :autosize="{ minRows: 2 }" :disabled="!stepThreePassed" placeholder="可补充其他风险点" /></label></div></section>
<section class="conclusion-section"><h4>(4)总结 PEST 环境评估结论</h4><el-input v-model.trim="conclusionForm.summary" type="textarea" :autosize="{ minRows: 3 }" :disabled="!stepThreePassed" placeholder="总结该产品的 PEST 环境评估结论" /></section>
</div>
</template>
<div v-else class="step-placeholder"><p>第 {{ currentStep }} 步内容待配置</p><span>前序步骤已保存,可从上方步骤栏返回查看。</span></div>
<div class="step-navigation">
<button v-if="currentStep > 1" type="button" class="btn-nav" :disabled="saving" @click="previousStep">上一步</button>
<button type="button" class="btn-nav btn-primary" :disabled="saving" @click="saveCurrentProgress">保存当前进度</button>
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary" :disabled="saving" @click="nextStep">下一步</button>
</div>
</section>
</section>
</main>
<TrainingAiSidebar study-tip="先从案例中提取宏观环境信息,再按政治、经济、社会、技术四个维度归类和补充。" reference-title="PEST 分类提示" reference-text="· 政治:政策、法规、认证、税收、数据合规<br />· 经济:市场规模、成本、商业模式、供应链<br />· 社会:人群需求、消费趋势、生活方式与场景<br />· 技术:产品技术、平台能力、设备互联与创新" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { DataAnalysis } from "@element-plus/icons-vue";
import { checkIndustryDemandAnalysisStepOne, checkIndustryDemandAnalysisStepTwo, checkIndustryDemandAnalysisStepThree, checkIndustryDemandAnalysisStepFour, deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer } 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 TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import TrainingStepOneChoiceQuestions from "@/views/components/TrainingStepOneChoiceQuestions.vue";
const TASK_KEY = "industry-demand-analysis";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const DEFAULT_STEPS = ["PEST宏观环境分析", "行业机会识别", "需求判断", "分析结论"];
const pestRows = [
{ key: "political", label: "政治环境", placeholder: "填写政策、法规、认证、税收或数据合规等信息" },
{ key: "economic", label: "经济环境", placeholder: "填写市场规模、成本、商业模式或供应链等信息" },
{ key: "social", label: "社会环境", placeholder: "填写用户需求、消费趋势、生活方式或场景变化等信息" },
{ key: "technology", label: "技术环境", placeholder: "填写产品技术、平台能力、设备互联或创新等信息" },
];
const pestAnalysisRows = [
{ key: "political", label: "政治环境(P)" },
{ key: "economic", label: "经济环境(E)" },
{ key: "social", label: "社会环境(S)" },
{ key: "technology", label: "技术环境(T)" },
];
const assessmentOptions = ["高度友好", "较为友好", "中性", "存在风险", "风险较高"];
const decisionOptions = ["建议进入", "谨慎进入", "建议暂缓"];
const defaultTask = {
title: "行业需求分析",
background: "通过 PEST 模型识别影响行业需求的宏观环境因素,为后续产品机会判断提供依据。",
goals: ["掌握 PEST 四维度的分类方法", "提炼案例中的宏观环境信息", "形成行业需求分析的基础材料"],
requirement: "请阅读案例资料,将相关信息归类到政治、经济、社会和技术环境中,并补充自行收集的行业资讯。",
};
const taskConfig = ref(null);
const briefOpen = ref(false);
const currentStep = ref(1);
const saving = ref(false);
const draftReady = ref(false);
const stepOnePassed = ref(false);
const stepTwoPassed = ref(false);
const stepThreePassed = ref(false);
const form = ref(createForm());
const stepOneChoiceAnswers = ref({});
const supplementForm = ref(createForm());
const analysisForm = ref(createAnalysisForm());
const conclusionForm = ref(createConclusionForm());
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 progressItems = computed(() => pestRows.map((row) => ({ text: `${conclusionForm.value.summary ? "已总结" : analysisForm.value[row.key].opportunities ? "已分析" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: conclusionForm.value.summary || analysisForm.value[row.key].opportunities || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" })));
onMounted(async () => { await loadTaskConfig(); loadDraft(); await loadSavedAnswer(); draftReady.value = true; });
watch(() => JSON.stringify(buildDraft()), persistDraft);
function createForm() { return { political: "", economic: "", social: "", technology: "" }; }
function createAnalysisForm() { return { political: createAnalysisRow(), economic: createAnalysisRow(), social: createAnalysisRow(), technology: createAnalysisRow() }; }
function createAnalysisRow() { return { opportunities: "", challenges: "", strategies: "" }; }
function createConclusionForm() { return { overallAssessment: "", marketDecision: "", decisionReason: "", riskOne: "", riskTwo: "", otherRisks: "", summary: "" }; }
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, form); stepOnePassed.value = true; clearDraft(); }
if (answer?.step2Answer) { restoreOutcome(answer.step2Answer, supplementForm, "supplements"); stepTwoPassed.value = true; }
if (answer?.step3Answer) { restoreAnalysisOutcome(answer.step3Answer); stepThreePassed.value = true; }
if (answer?.step4Answer) restoreConclusionOutcome(answer.step4Answer);
if (Number(answer?.currentStep) > 0) currentStep.value = Number(answer.currentStep);
} catch (error) { /* Keep the local draft when the answer endpoint is unavailable. */ }
}
function restoreOutcome(value, target, field = "pest") { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; target.value = { ...createForm(), ...(parsed?.[field] || parsed || {}) }; if (field === "pest" && parsed?.choiceAnswers && typeof parsed.choiceAnswers === "object") stepOneChoiceAnswers.value = parsed.choiceAnswers; } catch (error) { target.value = createForm(); } }
function restoreAnalysisOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const source = parsed?.analysis || parsed || {}; analysisForm.value = Object.fromEntries(pestAnalysisRows.map((row) => [row.key, { ...createAnalysisRow(), ...(source[row.key] || {}) }])); } catch (error) { analysisForm.value = createAnalysisForm(); } }
function restoreConclusionOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; conclusionForm.value = { ...createConclusionForm(), ...(parsed?.conclusion || parsed || {}) }; } catch (error) { conclusionForm.value = createConclusionForm(); } }
function buildStepOneOutcome() { return { title: taskTitle.value, pest: { ...form.value }, choiceAnswers: { ...stepOneChoiceAnswers.value } }; }
function buildStepTwoOutcome() { return { title: taskTitle.value, supplements: { ...supplementForm.value } }; }
function buildStepThreeOutcome() { return { title: taskTitle.value, analysis: JSON.parse(JSON.stringify(analysisForm.value)) }; }
function buildStepFourOutcome() { return { title: taskTitle.value, conclusion: { ...conclusionForm.value } }; }
function buildDraft() { return { step1: buildStepOneOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), currentStep: currentStep.value }; }
function getLocalDraftKey() { const info = userStore.userInfo || {}; return `${TASK_KEY}:draft:${info.userId || info.user_id || info.username || info.userName || "anonymous"}`; }
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, form); restoreOutcome(draft?.step2, supplementForm, "supplements"); restoreAnalysisOutcome(draft?.step3); restoreConclusionOutcome(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 switchStep(step) { if (step === 2 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成并保存第 1 步"); return; } if (step === 3 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成并保存第 2 步"); return; } if (step === 4 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成并保存第 3 步"); return; } currentStep.value = step; }
async function saveAndGoToStepTwo() { if (saving.value) return; saving.value = true; try { await checkIndustryDemandAnalysisStepOne(form.value); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }); stepOnePassed.value = true; clearDraft(); currentStep.value = 2; proxy?.$modal?.msgSuccess("已保存,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写 PEST 四类信息"); } finally { saving.value = false; } }
async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; saving.value = true; try { await checkIndustryDemandAnalysisStepTwo(supplementForm.value); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; clearDraft(); currentStep.value = 3; proxy?.$modal?.msgSuccess("已保存,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请为每个 PEST 维度至少补充 1 条信息"); } finally { saving.value = false; } }
async function saveAndGoToStepFour() { if (saving.value || !stepTwoPassed.value) return; saving.value = true; try { await checkIndustryDemandAnalysisStepThree({ rows: pestAnalysisRows.map((row) => ({ dimension: row.key, ...analysisForm.value[row.key] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreePassed.value = true; clearDraft(); currentStep.value = 4; proxy?.$modal?.msgSuccess("已保存,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成每个 PEST 维度的机遇、挑战和应对思路"); } finally { saving.value = false; } }
async function submitTraining() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkIndustryDemandAnalysisStepFour(conclusionForm.value); 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 || "请完成 PEST 环境评估结论"); } finally { saving.value = false; } }
async function saveCurrentStepFour() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkIndustryDemandAnalysisStepFour(conclusionForm.value); await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); clearDraft(); proxy?.$modal?.msgSuccess("第 4 步已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成 PEST 环境评估结论"); } finally { saving.value = false; } }
async function saveCurrentProgress(showMessage = true) {
if (saving.value) return false;
saving.value = true;
try {
const payloads = {
1: async () => { await checkIndustryDemandAnalysisStepOne(form.value); return { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 1 }; },
2: async () => { if (!stepOnePassed.value) throw new Error("请先完成第 1 步"); await checkIndustryDemandAnalysisStepTwo(supplementForm.value); return { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 2 }; },
3: async () => { if (!stepTwoPassed.value) throw new Error("请先完成第 2 步"); await checkIndustryDemandAnalysisStepThree({ rows: pestAnalysisRows.map((row) => ({ dimension: row.key, ...analysisForm.value[row.key] })) }); return { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 3 }; },
4: async () => { if (!stepThreePassed.value) throw new Error("请先完成第 3 步"); await checkIndustryDemandAnalysisStepFour(conclusionForm.value); return { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4 }; },
};
const answer = await payloads[currentStep.value]?.();
if (!answer) return false;
await saveStudentTrainingAnswer(TASK_KEY, { ...answer, submitted: false, saveAction: "SAVE" });
if (currentStep.value === 1) stepOnePassed.value = true;
if (currentStep.value === 2) stepTwoPassed.value = true;
if (currentStep.value === 3) stepThreePassed.value = true;
clearDraft();
if (showMessage) proxy?.$modal?.msgSuccess("当前步骤已保存");
return true;
} catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写当前步骤内容"); return false; } finally { saving.value = false; }
}
function previousStep() { if (currentStep.value > 1) currentStep.value -= 1; }
async function nextStep() { if (currentStep.value < trainingSteps.value.length && await saveCurrentProgress(false)) currentStep.value += 1; }
async function resetTraining() { form.value = createForm(); stepOneChoiceAnswers.value = {}; supplementForm.value = createForm(); analysisForm.value = createAnalysisForm(); conclusionForm.value = createConclusionForm(); stepOnePassed.value = false; stepTwoPassed.value = false; stepThreePassed.value = false; currentStep.value = 1; clearDraft(); try { saving.value = true; await deleteStudentTrainingAnswer(TASK_KEY); } catch (error) { /* Local clearing still succeeds. */ } finally { saving.value = false; } proxy?.$modal?.msgSuccess("已重新开始本次实训"); }
function resetStepOne() { form.value = createForm(); stepOneChoiceAnswers.value = {}; stepOnePassed.value = false; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
function resetStepTwo() { supplementForm.value = createForm(); proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function resetStepThree() { analysisForm.value = createAnalysisForm(); proxy?.$modal?.msgSuccess("已清空第 3 步填写内容"); }
function resetStepFour() { conclusionForm.value = createConclusionForm(); proxy?.$modal?.msgSuccess("已清空第 4 步填写内容"); }
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>
/* ===== 页面级令牌(墨黑极简主题) ===== */
.industry-pest-page {
--student-accent: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
/* 不设 grid-template-columns,由全局 .student-training-shell 单列布局接管 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 对齐 foundation 的 .training-core:只设 gap,无边框/背景/阴影 */
.industry-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* 任务头:独立卡片(对齐 foundation 的 .task-header) */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-badge,
.section-eyebrow {
color: var(--student-text-secondary);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
text-transform: none;
letter-spacing: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
/* task-header 内部主列(对齐 foundation 的 .task-head-main) */
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
.industry-demand-toolbar {
flex: 0 0 auto;
justify-content: flex-end;
max-width: 100%;
}
/* step-card 层:透明容器,去掉旧的 border/bg(对齐 foundation 的 .task-card) */
.industry-step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-chip {
border: 1px solid var(--student-panel-border);
border-radius: 6px;
padding: 3px 10px;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.step-desc,
.research-guide {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.step-desc { margin: 14px 0; }
.research-guide {
margin: 14px 0 12px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
}
.research-guide p { margin: 0 0 6px; }
.research-guide ol { margin: 0 0 6px; padding-left: 20px; }
.research-guide li + li { margin-top: 4px; }
.step-warning {
margin: 10px 0;
color: #b45309;
font-size: var(--student-fs-body-sm);
}
/* ===== 表格 ===== */
.pest-table-wrap,
.analysis-table-wrap { overflow-x: auto; }
.pest-table,
.analysis-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.pest-table th,
.pest-table td,
.analysis-table th,
.analysis-table td {
border: 1px solid var(--student-panel-border);
}
.pest-table th,
.analysis-table th {
padding: 10px 12px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.pest-table th:first-child,
.pest-table td:first-child,
.analysis-table th:first-child,
.analysis-table td:first-child {
width: 8%;
text-align: center;
}
.pest-table th:nth-child(2),
.pest-table .type-cell {
width: 18%;
text-align: center;
font-weight: 700;
}
.analysis-table th:nth-child(2),
.analysis-table .type-cell {
width: 14%;
text-align: center;
font-weight: 700;
}
.pest-table textarea,
.analysis-table textarea,
.conclusion-section textarea {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 120px;
resize: vertical;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
padding: 10px 12px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-body);
line-height: 1.55;
}
.pest-table textarea,
.analysis-table textarea {
border: 0;
border-radius: 0;
background: transparent;
padding: 8px 10px;
}
.analysis-table textarea { min-height: 140px; }
.pest-table textarea:focus,
.analysis-table textarea:focus,
.conclusion-section textarea:focus {
outline: 2px solid rgba(31, 41, 55, 0.2);
outline-offset: -2px;
}
.pest-table textarea:disabled,
.analysis-table textarea:disabled,
.conclusion-section textarea:disabled {
cursor: not-allowed;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
}
/* ===== 结论表单 ===== */
.conclusion-form { display: grid; gap: 14px; }
.conclusion-section {
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.conclusion-section h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.conclusion-section p {
margin: 6px 0 10px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.55;
}
.option-row {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-bottom: 12px;
}
.option-card {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 100px;
padding: 8px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
font-size: var(--student-fs-body);
}
.option-card:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.option-card input { accent-color: var(--student-accent); }
.risk-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.risk-grid label {
display: grid;
gap: 6px;
color: var(--student-text);
font-weight: 600;
font-size: var(--student-fs-body-sm);
}
.risk-grid textarea {
min-height: 120px;
font-weight: 400;
font-size: var(--student-fs-body);
}
/* ===== 底部步骤导航按钮(scoped 基础) ===== */
.step-navigation {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
.step-navigation .btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}
.step-navigation .btn-nav:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.step-navigation .btn-nav.btn-primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-weight: 700;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.step-navigation .btn-nav.btn-primary:hover {
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.32);
}
.step-navigation .btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-placeholder {
padding: 60px 20px;
text-align: center;
color: var(--student-text-secondary);
}
.step-placeholder p {
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 600;
}
/* ===== 响应式 ===== */
@media (max-width: 840px) {
.risk-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.task-header { flex-direction: column; align-items: flex-start; }
.industry-demand-toolbar { width: 100%; justify-content: flex-start; }
.step-chip { display: none; }
.step-navigation { flex-direction: column; }
.step-navigation .btn-nav { width: 100%; }
}
</style>
<style lang="scss">
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.industry-pest-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector { display: none !important; }
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
/* 编号小徽章(原 .step-marker 是圆形大圈,改为方形小徽章) */
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action { display: none !important; }
/* 未到达项 hover */
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成 */
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤 */
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.industry-pest-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.industry-pest-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
.industry-pest-page .training-task-toolbar { gap: 8px; }
.industry-pest-page .task-toolbar-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.industry-pest-page .task-toolbar-button.el-button .el-icon {
font-size: 14px;
color: inherit;
}
.industry-pest-page .task-toolbar-button.el-button:hover,
.industry-pest-page .task-toolbar-button.el-button:focus {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
.industry-pest-page .task-toolbar-button--submit.el-button {
border-color: #1f2937;
color: #ffffff;
background: #1f2937;
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.22);
}
.industry-pest-page .task-toolbar-button--submit.el-button:hover,
.industry-pest-page .task-toolbar-button--submit.el-button:focus {
border-color: #111827;
color: #ffffff;
background: #111827;
box-shadow: 0 4px 12px rgba(17, 24, 39, 0.28);
}
.industry-pest-page .task-toolbar-button.el-button.is-loading {
border-color: #d1d5db;
color: #374151;
background: #ffffff;
}
</style>