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

847 lines
40 KiB
Vue

<template>
<div class="industry-pest-page student-training-shell">
<main class="industry-core">
<section class="task-header">
<div class="task-head-main">
<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: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
--student-danger: #ef4444;
--student-success: #2ba471;
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
/* 字号梯度保留原样 */
--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;
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;
}
/* 任务头:白卡 + 左侧教育蓝竖条 + 12px 圆角 */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-badge,
.section-eyebrow {
color: var(--student-text-secondary);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.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(37, 99, 235, 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: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
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(37, 99, 235, 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(37, 99, 235, 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(29, 78, 216, 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: 0 !important;
width: 100% !important;
margin: 0 0 20px !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: 1 1 0 !important;
max-width: 240px !important;
min-width: 0 !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !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: normal !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child { border-radius: 8px 0 0 8px !important; }
&:last-child { border-radius: 0 8px 8px 0 !important; border-right: 1px solid #e5e7eb !important; }
.step-marker {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 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:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-marker { background: #2563eb !important; color: #ffffff !important; }
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-marker { background: rgba(43, 164, 113, 0.12) !important; color: #16915f !important; }
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-marker { background: #2ba471 !important; color: #ffffff !important; }
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-marker { background: #ffffff !important; color: #2563eb !important; }
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.industry-pest-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.industry-pest-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
.industry-pest-page .task-toolbar-button.el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 12px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
.industry-pest-page .task-toolbar-button--submit {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35), 0 3px 10px rgba(37, 99, 235, 0.22) !important;
&:hover:not(.is-disabled) {
background: #1d4ed8 !important;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.4), 0 5px 14px rgba(29, 78, 216, 0.28) !important;
}
}
</style>