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...

1870 lines
63 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="product-process student-training-shell">
<main class="process-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<div 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>
</div>
</div>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl" :training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl" :guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl" :ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl" />
</section>
<section class="process-workbench">
<nav v-if="trainingSteps.length" class="step-indicator" aria-label="产品开发主要流程步骤">
<button v-for="(step, index) in trainingSteps" :key="`${step}-${index}`" 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" :title="step"
@click="currentStep = index + 1">
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-name">{{ step }}</span>
</button>
</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>
</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>
</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="resolveGanttImageUrl(ganttImage.url)" alt="已上传的甘特图"
@click="previewGanttImage" />
<figcaption>{{ ganttImage.name }}</figcaption>
</figure>
</section>
</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><b class="reflection-no">01</b>观察甘特图,思考电商运营在产品开发过程中,可以提前介入同步完成哪些工作,以缩短运营周期、提高效率。(如“预热蓄水”“内容种草”等)</span>
<textarea v-model.trim="stepFourForm.parallelWorkAnalysis"
:disabled="!stepThreePassed || checking"
placeholder="请结合甘特图说明可同步推进的电商运营工作、介入节点及预期效率提升"></textarea>
</label>
<div class="reflection-question"><b class="reflection-no">02</b>在互联网产品开发管理中,你认为哪些环节可以借助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初稿"></textarea></label>
<label class="reflection-field"><span>场景2:</span><textarea
v-model.trim="stepFourForm.aiScenarioTwo"
:disabled="!stepThreePassed || checking"
placeholder="例如:使用 AI 生成电商详情页文案、主图脚本或运营素材"></textarea></label>
</div>
</section>
</template>
<div v-else class="step-placeholder">
<p>第 {{ currentStep }} 步内容待配置</p><span>第 1 步答案已保存,可从上方步骤栏返回查看。</span>
</div>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" @click="prevStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving || uploadingGantt"
@click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(currentStep).padStart(2, '0') }} / {{
String(trainingSteps.length).padStart(2, '0') }}</span>
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary"
:disabled="checking || saving || uploadingGantt" @click="nextStep">
{{ checking ? '判题中…' : '下一步' }}
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="请根据产品开发流程中任务对总进度和最终落地效果的影响,判断其是否为关键任务。" reference-title="关键任务判断提示"
reference-text="· 关键任务:直接影响产品定义、核心技术验证、量产交付或上市节奏<br />· 非关键任务:可在核心任务约束下进行资源协调,不直接阻塞整体排期"
:progress-items="progressItems"></TrainingAiSidebar>
</div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, CircleCheck, 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 { isExternal } from "@/utils/validate"
import useUserStore from "@/store/modules/user"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.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 briefOpen = ref(true)
const checking = ref(false)
const saving = 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 resolveGanttImageUrl(url) {
const source = String(url || "").trim()
if (!source || source.startsWith("data:") || source.startsWith("blob:")) return source
if (!isExternal(source)) return source.startsWith("/") ? source : `/${source}`
try {
const parsed = new URL(source)
if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
return source
}
return source
}
function previewGanttImage() {
window.open(resolveGanttImageUrl(ganttImage.value.url), "_blank", "noopener,noreferrer")
}
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("导出成功")
}
function prevStep() {
if (currentStep.value > 1) {
currentStep.value -= 1
}
}
async function saveCurrentProgress() {
if (saving.value || uploadingGantt.value) return
saving.value = true
try {
let payload
if (currentStep.value === 1) {
payload = { step1Answer: JSON.stringify(buildOutcome()), currentStep: 1, submitted: false, saveAction: "SAVE" }
} else if (currentStep.value === 2) {
payload = { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }
} else if (currentStep.value === 3) {
payload = { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }
} else {
payload = { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }
}
await saveStudentTrainingAnswer(TASK_KEY, payload)
clearDraft()
proxy?.$modal?.msgSuccess("保存成功")
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "保存失败,请稍后重试")
} finally {
saving.value = false
}
}
async function nextStep() {
if (checking.value || saving.value) return
if (currentStep.value === 1) {
await saveAndGoToStepTwo()
} else if (currentStep.value === 2) {
await saveAndGoToStepThree()
} else if (currentStep.value === 3) {
await saveAndGoToStepFour()
}
}
</script>
<style lang="scss" scoped>
/*
* 页面级令牌(墨黑极简主题):纯白画布 + 墨黑主操作 + 中性灰层次。
* 覆盖全局主色/文字/边框令牌,使所有 var(--student-*) 规则自动重着色。
*/
.product-process {
/* 主色:教育蓝 */
--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;
/* Element Plus 主色变量 */
--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-training-shell,页面不再重复声明 */
min-width: 0;
color: var(--student-text);
}
/* 主列布局:容器栅格与内边距由全局 .student-training-shell 统一提供 */
.process-core {
display: grid;
gap: 10px;
align-content: start;
}
/* 任务头:白卡 + 左侧教育蓝竖条 + 徽章 + 标题 + 简介 */
.task-header {
position: relative;
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-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
line-height: 1.35;
font-weight: 700;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.task-brief-text {
display: block;
}
.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;
b {
color: var(--student-text);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* 工作台:白底卡片 + 12px 圆角 */
.process-workbench {
margin: 0;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
&::before {
display: none !important;
content: none !important;
}
}
/* ============================================================
* 操作工具栏横条:内联布局,去掉悬浮和边框
* ============================================================ */
.task-action-strip {
margin: 0;
padding: 10px 16px;
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* 步骤导航(极简文字下划线)的换肤见非 scoped 块 */
.step-name {
display: block;
overflow: hidden;
min-width: 0;
color: inherit;
text-overflow: ellipsis;
}
/* 步骤卡片:12px 圆角白卡 */
.process-step-card {
margin: 0;
padding: 18px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
}
.step-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.step-title-row h3 {
margin: 2px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-title-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* 标题行内联导出:次按钮的紧凑小号(继承 .outline-action 描边与圆角语言) */
.schedule-export-action {
min-height: 36px;
height: 36px;
padding: 0 14px;
font-size: var(--student-fs-body-sm);
font-weight: 600;
border-radius: 8px;
}
/* Step 编号小标签:教育蓝引导文字 */
.section-eyebrow {
margin: 0;
color: var(--student-accent);
font-size: var(--student-fs-caption);
font-weight: 800;
line-height: 1.3;
letter-spacing: 0.12em;
}
/* 步骤小徽章:教育蓝浅底 */
.step-chip {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 600;
white-space: nowrap;
}
/* 步骤说明 */
.process-intro {
margin: 0 0 12px;
color: #4b5563;
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* 表格:纯白 + 中性边框 */
.training-table-wrap {
overflow-x: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
}
.process-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
table-layout: fixed;
}
.process-table th {
padding: 9px 10px;
border-bottom: 1px solid #e5e7eb;
color: #374151;
background: #ffffff;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: center;
}
.process-table td {
padding: 9px 10px;
border-top: 1px solid #f0f1f3;
color: #374151;
background: #ffffff;
vertical-align: middle;
}
.stage-cell,
.task-no-cell {
color: #1f2937;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: center;
}
.task-cell {
color: #374151;
font-size: var(--student-fs-body-sm);
line-height: 1.55;
text-align: center;
}
/* 下拉与日期输入:中性描边,聚焦墨黑 */
.select-cell select,
.schedule-table input {
box-sizing: border-box;
width: 100%;
min-height: 34px;
padding: 5px 9px;
border: 1px solid #d1d5db;
border-radius: 8px;
outline: none;
color: #1f2937;
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-body-sm);
}
.select-cell select:focus,
.schedule-table input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* 判题反馈:仅文字着色,不铺行底色 */
.classification-feedback {
margin: 5px 0 0;
font-size: var(--student-fs-caption);
line-height: 1.4;
}
tr.is-correct .classification-feedback {
color: var(--student-success);
}
tr.is-incorrect .classification-feedback {
color: var(--student-danger);
}
/* 判题汇总横幅 */
.classification-summary {
margin: 12px 0 0;
padding: 8px 12px;
border-radius: 8px;
color: var(--student-danger);
background: rgba(217, 85, 76, 0.08);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
.classification-summary.is-success {
color: var(--student-success);
background: rgba(74, 157, 110, 0.08);
}
/* 步骤操作按钮:居中紧凑排列(与标准页 .step-navigation 同规格) */
.task-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
/*
* 次按钮(清空填写/本步):白底 + 墨黑半透明描边 + 墨黑字;
* 与标准样例一致:36px 高、8px 圆角、700 字重、hover 轻微浮起。
*/
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-body);
font-weight: 700;
line-height: 1;
text-shadow: none;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.btn-nav:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
color: var(--student-accent);
background: var(--student-accent-soft);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
.btn-nav:active:not(:disabled) {
transform: translateY(0);
}
/* 主按钮(保存进入下一步 / 提交任务):墨黑实底白字 + 中性柔影 */
.btn-nav.btn-submit {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
min-height: 36px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
}
.btn-nav.btn-submit:hover:not(:disabled) {
border-color: var(--student-accent-deep);
color: #ffffff;
background: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
/* ============================================================
* 底部步骤导航:上一步 / 保存 / 下一步
* ============================================================ */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-body);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: var(--student-fs-h2);
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
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-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-body);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: var(--student-fs-h2);
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-body);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: var(--student-fs-h2);
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
@media (max-width: 1100px) {
.step-nav-right {
margin-left: 0;
}
}
@media (max-width: 900px) {
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.btn-primary,
.btn-ghost,
.btn-save {
width: 100%;
}
}
/* 空步骤占位 */
.step-placeholder {
display: grid;
place-items: center;
min-height: 220px;
padding: 24px;
border: 1px dashed #d1d5db;
border-radius: 12px;
color: #9ca3af;
background: #fafbfc;
text-align: center;
}
.step-placeholder p {
margin: 0 0 6px;
color: #4b5563;
font-size: var(--student-fs-h2);
font-weight: 600;
}
/* 步骤门控提示:中性灰底,安静提示 */
.step-warning {
margin: 0 0 12px;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-left: 3px solid #9ca3af;
border-radius: 8px;
color: #6b7280;
background: #eff6ff;
font-size: var(--student-fs-body-sm);
}
/* 排期表列宽 */
.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: #1f2937;
font-size: var(--student-fs-body-sm);
font-weight: 700;
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: #9ca3af;
opacity: 1;
}
.schedule-table th:nth-child(7),
.schedule-table td:nth-child(7) {
width: 20%;
}
.schedule-table input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 甘特图上传区:白底 + 中性虚线,无光晕 */
.gantt-upload-panel {
display: grid;
justify-items: center;
gap: 16px;
min-height: 260px;
padding: 24px 16px;
border: 1px dashed #d1d5db;
border-radius: 12px;
background: #ffffff;
}
/* 上传触发器:教育蓝实底主操作 */
.gantt-upload-trigger {
display: grid;
place-items: center;
align-content: center;
gap: 8px;
width: 240px;
height: 110px;
border-radius: 10px;
color: #ffffff;
background: #2563eb;
cursor: pointer;
transition: background 0.18s ease;
}
.gantt-upload-trigger:hover:not(.disabled) {
background: #1d4ed8;
}
.gantt-upload-trigger input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.gantt-upload-trigger .el-icon {
font-size: 22px;
color: #ffffff;
}
.gantt-upload-trigger span {
color: #ffffff;
font-size: var(--student-fs-body-sm);
}
.gantt-upload-trigger.disabled {
cursor: not-allowed;
background: #9ca3af;
}
.gantt-preview {
width: min(100%, 720px);
margin: 0;
text-align: center;
}
.gantt-preview img {
display: block;
width: 100%;
max-height: 380px;
object-fit: contain;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: zoom-in;
}
.gantt-preview figcaption {
margin-top: 6px;
color: #9ca3af;
font-size: var(--student-fs-caption);
}
/* 复盘区 */
.process-reflection {
display: grid;
gap: 18px;
}
.reflection-field {
display: grid;
gap: 8px;
color: #4b5563;
font-size: var(--student-fs-body);
line-height: 1.55;
}
.reflection-field span {
color: #1f2937;
font-weight: 600;
}
.reflection-field textarea {
width: 100%;
box-sizing: border-box;
min-height: 150px;
resize: vertical;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #1f2937;
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-body);
line-height: 1.6;
}
.reflection-field textarea:focus {
outline: 0;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.reflection-field textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
background: #eff6ff;
}
.reflection-question {
color: #1f2937;
font-size: var(--student-fs-body);
font-weight: 600;
line-height: 1.6;
}
/* 第 4 步序号徽章:复刻参考稿——实底蓝正方形 + 白字前导零 */
.reflection-no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-right: 10px;
border-radius: 6px;
color: #ffffff;
background: #3b82f6;
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 600;
line-height: 1;
vertical-align: middle;
}
/* 两个场景并排 */
.scenario-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.scenario-grid .reflection-field textarea {
min-height: 150px;
}
.process-reflection.disabled {
opacity: 0.7;
}
@media (max-width: 1100px) {
.product-process {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.product-process {
padding: 12px;
}
.process-core {
padding: 14px;
border-radius: 16px;
}
.step-title-row {
flex-direction: column;
align-items: stretch;
}
.step-indicator {
width: 100%;
overflow-x: auto;
}
.step-tab {
flex: 0 0 auto;
}
.step-chip {
display: none;
}
.scenario-grid {
grid-template-columns: 1fr;
}
/* 按钮区窄屏全宽的强制覆盖见非 scoped 块 */
}
</style>
<style lang="scss">
/* ===== AI 侧栏样式已收敛进 TrainingAiSidebar 组件(墨黑极简),此处不再覆盖 ===== */
/* ============================================================
* 步骤导航:胶囊分段 + 连接(教育蓝主题)
* 与 new-product-survey.vue 完全一致,覆盖全局 !important 规则
* ============================================================ */
#app .studentLayout section.app-main:has(.product-process) .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-tab {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !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: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: nowrap !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-bar {
display: none !important;
}
/* 编号小徽章 */
.step-no {
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-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: var(--student-fs-body-sm) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
/* 未到达项 hover:教育蓝边浅底 */
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no {
background: #2563eb !important;
color: #ffffff !important;
}
}
/* 已完成:绿色编号徽章 */
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no {
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-no {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.product-process) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 按钮风格强制同步:教育蓝
* 全局 student-training.scss 用 !important 把 .task-actions 按钮锁成浅蓝大胶囊,
* scoped 规则无法取胜。这里用 id 锚点(特异性含 #app)+ !important 稳定覆盖。
* ============================================================ */
/* Step 02 标题行"导出列表":36px 紧凑小号 */
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 5px !important;
width: auto !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: var(--student-fs-body-sm) !important;
font-weight: 600 !important;
line-height: 1 !important;
white-space: nowrap !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action .el-icon {
color: inherit !important;
font-size: var(--student-fs-body-sm) !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: none !important;
}
/* --- 底部步骤按钮:36px 标准按钮 --- */
#app .studentLayout section.app-main:has(.product-process) .task-actions {
gap: 14px !important;
margin-top: 20px !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions>button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
width: auto !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: var(--student-fs-body) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions>button .el-icon {
display: inline-flex !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions>button:hover:not(:disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
/* 主按钮:教育蓝实底白字 + 蓝色柔影 */
#app .studentLayout section.app-main:has(.product-process) .task-actions>button.btn-submit {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions>button.btn-submit:hover:not(:disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions>button:disabled {
cursor: not-allowed !important;
opacity: 0.5 !important;
transform: none !important;
box-shadow: none !important;
}
/* 窄屏:按钮纵向全宽(全局容器 min-width/布局均为 !important,须在此覆盖) */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action,
#app .studentLayout section.app-main:has(.product-process) .task-actions>button {
width: 100% !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions {
flex-direction: column !important;
align-items: stretch !important;
}
/* task-action-strip 响应式 */
#app .studentLayout section.app-main:has(.product-process) .task-action-strip {
position: static !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-action-strip :deep(.training-task-toolbar) {
justify-content: stretch !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-action-strip :deep(.training-task-toolbar > *) {
flex: 1 1 auto !important;
}
}
</style>