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/product/requirements-doc.vue

1921 lines
70 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="requirements-doc-page student-training-shell">
<main class="requirements-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>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-export="exportOutcome" />
</section>
<section class="task-card">
<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: index === 0 && stepOnePassed }]"
@click="switchStep(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong>{{ step }}</strong></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
</template>
</nav>
<section v-if="currentStep === 1" class="step-card">
<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="step-desc">PRD
是产品开发的纲领性文件,通常包含文档说明、产品概述、用户需求与场景、核心功能需求、非功能需求、硬件规格与设计需求、测试需求、项目规划和需求变更管理等模块。请把右侧被打乱的"核心作用"卡片拖入左侧对应模块;全部匹配正确后才能进入第
2 步。</p>
<div class="matching-tip" role="status">
<el-icon>
<InfoFilled />
</el-icon>
<span>可直接拖拽卡片,也可先单击右侧卡片,再单击左侧空白槽位完成匹配。</span>
</div>
<div class="matching-workspace">
<section class="module-list" aria-label="PRD 模块匹配区">
<article v-for="(module, index) in MODULES" :key="module.id" class="module-row"
:class="{ 'module-row--over': dragOverModuleId === module.id, 'module-row--filled': assignments[module.id] }"
@dragover.prevent="dragOverModuleId = module.id" @dragleave="dragOverModuleId = ''"
@drop.prevent="dropIntoModule(module.id)" @click="assignSelected(module.id)">
<div class="module-label"><span>{{ String(index + 1).padStart(2, '0')
}}</span><strong>{{ module.name
}}</strong></div>
<div class="module-drop-zone">
<article v-if="assignedPurpose(module.id)"
class="purpose-card purpose-card--assigned" draggable="true"
@dragstart.stop="startDrag(assignedPurpose(module.id).id)"
@dragend="finishDrag">
<span>{{ assignedPurpose(module.id).text }}</span>
<button type="button" :aria-label="`移除${module.name}的匹配`"
@click.stop="removeAssignment(module.id)"><el-icon>
<Close />
</el-icon></button>
</article>
<span v-else class="drop-placeholder">{{ selectedPurposeId ? '单击此处放入已选卡片' :
'将核心作用拖到这里' }}</span>
</div>
</article>
</section>
<aside class="purpose-bank" :class="{ 'purpose-bank--over': dragOverModuleId === 'bank' }"
@dragover.prevent="dragOverModuleId = 'bank'" @dragleave="dragOverModuleId = ''"
@drop.prevent="returnDraggedToBank">
<div class="purpose-bank__head">
<div>
<p class="section-eyebrow">待匹配核心作用</p>
<h4>拖拽卡片到左侧模块</h4>
</div>
<strong>{{ unassignedPurposes.length }}</strong>
</div>
<p>卡片顺序已被打乱;标准答案仅在服务端保存,提交后由系统判题。</p>
<div class="purpose-card-list">
<button v-for="purpose in unassignedPurposes" :key="purpose.id" type="button"
class="purpose-card"
:class="{ selected: selectedPurposeId === purpose.id, dragging: draggingPurposeId === purpose.id }"
draggable="true" @click="togglePurposeSelection(purpose.id)"
@dragstart="startDrag(purpose.id)" @dragend="finishDrag">
<span class="drag-grip">⠿</span><span>{{ purpose.text }}</span>
</button>
<div v-if="!unassignedPurposes.length" class="purpose-bank__empty">全部核心作用已放入匹配区</div>
</div>
</aside>
</div>
<p v-if="checkResult" class="result-message" :class="{ success: checkResult.allCorrect }">{{
checkResult.message }}
</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="checking || saving"
@click="resetMatching"><el-icon>
<RefreshLeft />
</el-icon>重新打乱</button>
</div>
</section>
<section v-else-if="currentStep === 2" class="step-card prd-detail-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 02</p>
<h3>{{ trainingSteps[1] }}</h3>
</div>
<span class="step-chip">可编辑 PRD 架构表</span>
</div>
<p class="step-desc">PRD 的每一个模块都需要言之有物、数据支撑。请基于案例资料和前期实训成果,完成以下七大模块的详细信息。</p>
<div class="prd-note"><el-icon>
<InfoFilled />
</el-icon><span>灰色文字为参考示例,可点击"填入示例"后再修改。导出表格会同时包含示例内容和你的填写内容。</span></div>
<div class="prd-table-wrap">
<table class="prd-detail-table">
<thead>
<tr>
<th>序号</th>
<th>模块</th>
<th>子模块</th>
<th>填写内容</th>
</tr>
</thead>
<tbody>
<template v-for="module in PRD_DETAIL_MODULES" :key="module.id">
<tr v-for="(row, index) in module.rows" :key="row.id">
<td v-if="index === 0" :rowspan="module.rows.length" class="module-number">{{
module.order }}</td>
<td v-if="index === 0" :rowspan="module.rows.length" class="module-name">{{
module.name }}</td>
<td class="submodule-name">{{ row.name }}</td>
<td class="detail-editor-cell">
<div v-if="row.example" class="example-bar"><span>示例:{{ row.example
}}</span><button type="button"
@click="fillExample(row)">填入示例</button></div>
<textarea v-model="stepTwoContents[row.id]"
:aria-label="`${module.name}-${row.name}`"
placeholder="请基于案例资料和前期实训成果填写" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" @click="exportStepTwoTable"><el-icon>
<Download />
</el-icon>导出 PRD 表格</button>
</div>
</section>
<section v-else-if="currentStep === 3" class="step-card prd-upload-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 03</p>
<h3>{{ trainingSteps[2] || '生成产品需求文档' }}</h3>
</div>
<span class="step-chip">Word / PDF 在线预览</span>
</div>
<p class="step-desc">PRD 是跨团队协作的基准文档。请基于第 2 步的核心模块信息,结合案例资料中的完整数据,生成一份完整的产品需求文档后上传。</p>
<div class="prd-delivery-guide">
<ol>
<li>下载 Word 版 PRD 空白模板后自行撰写。</li>
<li>也可借助 AI:请根据产品需求文档框架和案例文档信息,撰写一份产品需求说明书。</li>
</ol>
<span>支持 .docx、.pdf,单个文件不超过 20MB;上传后可在线预览。</span>
</div>
<input ref="documentInput" class="file-input-hidden" type="file"
accept=".docx,.pdf,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@change="handleDocumentUpload" />
<div v-if="!stepThreeDocument.name" class="document-drop-zone" role="button" tabindex="0"
@click="triggerDocumentUpload" @keyup.enter="triggerDocumentUpload">
<div class="document-drop-zone__icon"><el-icon>
<UploadFilled />
</el-icon></div>
<strong>{{ documentUploading ? '正在解析并上传…' : '上传产品需求文档' }}</strong>
<span>Word(.docx)或 PDF</span>
</div>
<article v-else class="uploaded-document-card">
<div class="uploaded-document-icon"><el-icon>
<Document />
</el-icon></div>
<div class="uploaded-document-main">
<strong>{{ stepThreeDocument.name }}</strong>
<span>{{ stepThreeDocument.fileType === 'PDF' ? 'PDF 文档' : 'Word 文档' }} · {{
formatFileSize(stepThreeDocument.size) }}</span>
</div>
<div class="uploaded-document-actions">
<button type="button" class="btn-nav btn-submit"
@click="showDocumentPreview = true"><el-icon>
<View />
</el-icon>在线预览</button>
<button type="button" class="btn-nav" :disabled="documentUploading"
@click="triggerDocumentUpload">替换</button>
<button type="button" class="btn-nav btn-danger" :disabled="documentUploading"
@click="removeDocument"><el-icon>
<Delete />
</el-icon>删除</button>
</div>
</article>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit"
:disabled="!isDocumentReady || documentUploading"
@click="showDocumentPreview = true"><el-icon>
<View />
</el-icon>预览文档</button>
</div>
</section>
<section v-else class="step-card upcoming-card">
<p class="section-eyebrow">Step {{ String(currentStep).padStart(2, '0') }}</p>
<h3>{{ trainingSteps[currentStep - 1] || '后续步骤' }}</h3>
<p>该步骤将在对应实训页面继续完成。</p>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
@click="previousStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving || checking"
@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(IMPLEMENTED_STEP_COUNT).padStart(2, "0") }}</span>
<button v-if="currentStep < IMPLEMENTED_STEP_COUNT" type="button" class="btn-nav btn-primary"
:disabled="saving" @click="nextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
<el-dialog v-model="showDocumentPreview" :title="stepThreeDocument.name || '产品需求文档预览'"
width="min(1040px, 94vw)" class="prd-preview-dialog" append-to-body>
<div v-if="stepThreeDocument.fileType === 'PDF' && stepThreeDocument.url" class="prd-pdf-preview">
<iframe :src="stepThreeDocument.url" :title="`${stepThreeDocument.name} PDF 预览`" />
</div>
<div v-else-if="stepThreeDocument.previewContent" class="prd-text-preview">{{
stepThreeDocument.previewContent }}
</div>
<p v-else class="prd-preview-empty">暂未解析到可预览的文本内容,可下载原文件查看。</p>
</el-dialog>
</main>
<TrainingAiSidebar :study-tip="sidebarStudyTip"
:reference-title="currentStep === 3 ? 'PRD 文档交付提示' : currentStep === 2 ? 'PRD 详细信息填写提示' : 'PRD 模块判断提示'"
:reference-text="sidebarReferenceText" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck, Close, Delete, Document, Download, InfoFilled, RefreshLeft, UploadFilled, View } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { checkRequirementsDocumentStepOne, getStudentTrainingAnswer, previewRequirementsDocumentFile, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import { isStudentDemo } from "@/utils/studentDemo"
const TASK_KEY = "requirements-doc"
const { proxy } = getCurrentInstance()
const MODULES = [
{ id: "document-info", name: "文档说明" }, { id: "product-overview", name: "产品概述" }, { id: "user-needs-scenarios", name: "用户需求与场景" },
{ id: "core-functional-requirements", name: "核心功能需求" }, { id: "non-functional-requirements", name: "非功能需求" }, { id: "hardware-spec-design", name: "硬件规格与设计需求" },
{ id: "testing-requirements", name: "测试需求" }, { id: "project-planning", name: "项目规划" }, { id: "change-management", name: "需求变更管理" },
]
const PURPOSES = [
{ id: "functional-scope", text: "详细规定产品具体做什么,是开发与测试的直接依据。" },
{ id: "acceptance-criteria", text: "明确验收标准,避免测试与产品理解不一致。" },
{ id: "quality-attributes", text: "定义产品的「质量属性」,决定产品好不好用、稳不稳定。" },
{ id: "document-governance", text: "建立文档本身的权威性与可追溯性,确保所有人看到的是同一份有效版本。" },
{ id: "change-control", text: "确保每一次变更都经过评估、记录和通知。" },
{ id: "user-context", text: "从用户视角出发,让团队在开发时能代入真实使用情境。" },
{ id: "product-boundary", text: "回答「为什么做」和「做成什么样」,赋予开发团队价值感,划定产品边界。" },
{ id: "hardware-constraints", text: "明确硬件与软件的通信协议及设计约束。" },
{ id: "resource-plan", text: "将产品需求转化为时间与资源计划,用于管理预期。" },
]
const PRD_DETAIL_MODULES = [
{
id: "overview", order: "一", name: "产品概述", rows: [
{ id: "backgroundPain", name: "1. 项目背景与痛点", example: "年轻租房群体夜间起夜开灯不便,需摸黑找开关;母婴家庭夜间喂奶、换尿布需柔和不刺眼光源。传统小夜灯功能单一,无法满足差异化场景需求,市场存在智能多场景小夜灯的空白机会。" },
{ id: "positioningTarget", name: "2. 产品定位与目标用户", example: "一句话概括,如:面向 X 人群的 Y 类型产品" },
{ id: "coreGoal", name: "3. 核心目标", example: "可量化的业务指标" },
{ id: "productScope", name: "4. 产品范围", example: "明确本期做与不做的内容。" },
]
},
{
id: "user", order: "二", name: "用户需求与场景", rows: [
{ id: "persona", name: "1. 用户画像", example: "职场妈妈李婷,30 岁,宝宝 8 个月。夜间需频繁起夜喂奶/安抚,对光线敏感度极高,希望灯光柔和且能声控、手机控,不打扰宝宝睡眠节奏。" },
{ id: "usageScenario", name: "2. 用户场景", example: "在什么时间、地点、触发条件下使用" },
{ id: "painAndNeed", name: "3. 用户痛点与诉求", example: "用户表面的抱怨和底层的真实渴望" },
{ id: "journeyMap", name: "4. 用户旅程地图", example: "用户完成任务的全流程触点" },
]
},
{
id: "core", order: "三", name: "核心功能需求", rows: [
{ id: "featureOverview", name: "1. 功能模块概览", example: "①语音控制模块(离线唤醒+指令识别)②智能调光模块(亮度和色温多档可调)③场景联动模块(定时/感应/APP 远程)④睡眠辅助模块(白噪声/助眠灯光模式)" },
{ id: "featureDetails", name: "2. 详细功能描述", example: "优先级:P0(必须有)、P1(应该有)、P2(可以有);说明交互流程、操作路径与软硬件交互规则。" },
]
},
{
id: "nonfunctional", order: "四", name: "非功能需求", rows: [
{ id: "performance", name: "1. 性能需求", example: "核心功能的性能要求,如响应时间" },
{ id: "security", name: "2. 安全需求", example: "如:产品防火防水、数据加密等" },
{ id: "usability", name: "3. 可用性与易用性", example: "操作容错率、学习成本" },
{ id: "compatibility", name: "4. 兼容性", example: "软硬件跟主流系统之间的兼容性" },
]
},
{
id: "hardware", order: "五", name: "硬件规格与设计需求", rows: [
{ id: "hardwareSpecs", name: "1. 硬件参数", example: "如:芯片型号、传感器规格、屏幕尺寸/分辨率" },
{ id: "interfaces", name: "2. 接口与协议", example: "蓝牙、Wi-Fi、串口通信的数据格式(如:JSON/XML 结构)" },
{ id: "powerHeat", name: "3. 功耗与散热", example: "续航时间要求、温度控制范围" },
{ id: "structureConstraints", name: "4. 结构设计约束", example: "对软件 UI 的物理限制(如:必须适配圆形屏幕)" },
]
},
{
id: "testing", order: "六", name: "测试需求", rows: [
{ id: "acceptance", name: "1. 验收标准", example: "针对每个核心功能的通过条件" },
{ id: "testingFocus", name: "2. 测试重点", example: "高风险模块、复杂交互逻辑的测试关注点" },
{ id: "testMatrix", name: "3. 兼容性测试矩阵", example: "需要覆盖的设备/系统组合" },
]
},
{
id: "planning", order: "七", name: "项目规划", rows: [
{ id: "milestones", name: "1. 里程碑", example: "需求评审日、交互定稿日、提测日、上线日" },
{ id: "resourceDependencies", name: "2. 资源依赖", example: "需要 UI 设计、后端接口、外部第三方合作的配合时间点" },
{ id: "riskAssessment", name: "3. 风险评估", example: "当前识别的最大风险点" },
]
},
]
const DEFAULT_STEPS = ["PRD 模块核心作用匹配", "PRD 信息架构梳理", "生成产品需求文档", "PRD 文档评审"]
/* 本页实现 3 个步骤,第 4 步(PRD 文档评审)在对应实训页面继续完成 */
const IMPLEMENTED_STEP_COUNT = 3
const fallbackTask = {
title: "产品需求文档撰写",
background: "PRD 是连接产品、设计、研发和测试的纲领性文件。本实训先建立对 PRD 模块职责的清晰认识,再逐步完成需求文档撰写。",
goals: ["识别 PRD 的核心模块", "理解各模块在产品开发中的作用", "建立结构化撰写产品需求文档的基础"],
requirement: "完成 PRD 核心模块匹配及七大模块详细信息填写。",
}
const taskConfig = ref(null)
const briefOpen = ref(true)
const currentStep = ref(1)
const assignments = ref({})
const stepTwoContents = ref({})
const stepThreeDocument = ref({ name: "", url: "", fileType: "", size: 0, previewContent: "" })
const documentInput = ref(null)
const documentUploading = ref(false)
const showDocumentPreview = ref(false)
const selectedPurposeId = ref("")
const draggingPurposeId = ref("")
const dragOverModuleId = ref("")
const checking = ref(false)
const saving = ref(false)
const submitting = ref(false)
const stepOnePassed = ref(false)
const checkResult = ref(null)
const taskTitle = computed(() => taskConfig.value?.taskName || fallbackTask.title)
const taskBackground = computed(() => taskConfig.value?.background || fallbackTask.background)
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, fallbackTask.goals))
const taskRequirement = computed(() => taskConfig.value?.requirements || fallbackTask.requirement)
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS))
const progressItems = computed(() => trainingSteps.value.map((text, index) => ({ text, status: index === 0 ? (stepOnePassed.value ? "done" : "doing") : index === 1 && currentStep.value >= 2 ? "done" : index === 2 && currentStep.value >= 3 ? "doing" : "" })))
const unassignedPurposes = computed(() => PURPOSES.filter((purpose) => !Object.values(assignments.value).includes(purpose.id)))
const allAssigned = computed(() => MODULES.every((module) => Boolean(assignments.value[module.id])) && Object.keys(assignments.value).length === MODULES.length)
const isDocumentReady = computed(() => Boolean(stepThreeDocument.value.name && stepThreeDocument.value.url))
const canSubmitTask = computed(() => stepOnePassed.value && isDocumentReady.value)
const sidebarStudyTip = computed(() => currentStep.value === 3
? "先依据第 2 步的模块信息整理成完整 PRD,再上传 .docx 或 .pdf。上传完成后请打开在线预览,确认文档内容和版本无误。"
: currentStep.value === 2
? "先用前期实训的真实资料填入七大模块。优先把用户、场景、痛点和功能的链路写清楚,再补充指标、边界、测试与规划。"
: "先辨识每个 PRD 模块的职责边界:用户需求解释使用情境,核心功能定义产品要做什么,非功能需求定义质量属性;再完成匹配。"
)
const sidebarReferenceText = computed(() => currentStep.value === 3
? "• 文档应覆盖产品概述、用户与场景、核心功能、非功能需求、硬件规格、测试与项目规划<br />• 支持 .docx 和 PDF 在线预览;替换文件会保留最新提交版本<br />• 保存后可再次进入本步骤查看或替换文档"
: currentStep.value === 2
? "• 填写内容应基于案例资料或前期实训结果,避免只复述标题<br />• 示例是格式参考,可复制后结合自己的产品修改<br />• 导出的 PRD 表会保留示例参考与学生填写内容"
: "• 文档说明:版本、作者、状态等文档治理信息<br />• 核心功能需求:开发和测试可直接执行的功能范围<br />• 非功能需求:性能、稳定性、易用性等质量属性<br />• 需求变更管理:评估、记录、通知每次需求变更"
)
onMounted(async () => { await loadTaskConfig(); await loadSavedProgress() })
function assignedPurpose(moduleId) { return PURPOSES.find((purpose) => purpose.id === assignments.value[moduleId]) || null }
function parseSavedAnswer(raw) { try { return raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null } catch (_) { return null } }
function normalizeAssignments(value) {
if (!value || typeof value !== "object") return null
const copied = {}
for (const module of MODULES) {
const purposeId = String(value[module.id] || "")
if (PURPOSES.some((purpose) => purpose.id === purposeId)) copied[module.id] = purposeId
}
return new Set(Object.values(copied)).size === Object.keys(copied).length ? copied : null
}
function normalizeStepTwoContents(value) {
const saved = value?.contents && typeof value.contents === "object" ? value.contents : value
if (!saved || typeof saved !== "object") return {}
const validIds = new Set(PRD_DETAIL_MODULES.flatMap((module) => module.rows.map((row) => row.id)))
return Object.fromEntries(Object.entries(saved).filter(([key, text]) => validIds.has(key) && typeof text === "string"))
}
function normalizeStepThreeDocument(value) {
const document = value?.document && typeof value.document === "object" ? value.document : value
if (!document || typeof document !== "object" || !String(document.name || "").trim() || !String(document.url || "").trim()) return null
return {
name: String(document.name),
url: String(document.url),
fileType: String(document.fileType || "").toUpperCase(),
size: Number(document.size || 0),
previewContent: String(document.previewContent || ""),
}
}
function stepStateText(index) {
if (index === 0) return stepOnePassed.value ? "已通过" : "当前步骤"
if (index === 1) return stepOnePassed.value ? (currentStep.value === 2 ? "当前步骤" : "可填写") : "完成第 1 步后解锁"
if (index === 2) return stepOnePassed.value ? (currentStep.value === 3 ? "当前步骤" : isDocumentReady.value ? "已上传" : "可填写") : "完成第 1 步后解锁"
return currentStep.value > index + 1 ? "已完成" : "后续步骤"
}
function invalidateCheck() { checkResult.value = null; stepOnePassed.value = false }
function togglePurposeSelection(purposeId) { selectedPurposeId.value = selectedPurposeId.value === purposeId ? "" : purposeId }
function startDrag(purposeId) { if (!checking.value && !saving.value) { draggingPurposeId.value = purposeId; selectedPurposeId.value = purposeId } }
function finishDrag() { draggingPurposeId.value = ""; dragOverModuleId.value = "" }
function placePurpose(moduleId, purposeId) {
if (!moduleId || !purposeId) return
const next = { ...assignments.value }
Object.keys(next).forEach((key) => { if (next[key] === purposeId) delete next[key] })
next[moduleId] = purposeId
assignments.value = next
selectedPurposeId.value = ""
invalidateCheck()
}
function assignSelected(moduleId) { if (selectedPurposeId.value) placePurpose(moduleId, selectedPurposeId.value) }
function dropIntoModule(moduleId) { placePurpose(moduleId, draggingPurposeId.value || selectedPurposeId.value); finishDrag() }
function returnDraggedToBank() {
const moduleId = Object.keys(assignments.value).find((key) => assignments.value[key] === draggingPurposeId.value)
if (moduleId) { const next = { ...assignments.value }; delete next[moduleId]; assignments.value = next; invalidateCheck() }
finishDrag()
}
function removeAssignment(moduleId) { const next = { ...assignments.value }; delete next[moduleId]; assignments.value = next; invalidateCheck() }
function resetMatching() { assignments.value = {}; selectedPurposeId.value = ""; invalidateCheck() }
function buildStepOneAnswer() { return { assignments: { ...assignments.value }, passed: stepOnePassed.value, checkResult: checkResult.value, updatedAt: new Date().toISOString() } }
function buildStepTwoAnswer() { return { contents: { ...stepTwoContents.value }, updatedAt: new Date().toISOString() } }
function buildStepThreeAnswer() { return { document: { ...stepThreeDocument.value }, updatedAt: new Date().toISOString() } }
function buildSavePayload(current = currentStep.value) {
return { currentStep: current, step1Answer: JSON.stringify(buildStepOneAnswer()), step2Answer: JSON.stringify(buildStepTwoAnswer()), step3Answer: JSON.stringify(buildStepThreeAnswer()), submitted: false, saveAction: "SAVE" }
}
function buildSubmitPayload() { return { ...buildSavePayload(3), submitted: true, saveAction: "SUBMIT" } }
async function persist(current = currentStep.value) { await saveStudentTrainingAnswer(TASK_KEY, buildSavePayload(current)) }
async function saveCurrentMatching() {
if (saving.value) return
saving.value = true
try { await persist(stepOnePassed.value ? 2 : 1); proxy?.$modal?.msgSuccess("当前匹配已保存") } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试") } finally { saving.value = false }
}
async function checkAndSave() {
if (!allAssigned.value || checking.value) return
checking.value = true
try {
const response = await checkRequirementsDocumentStepOne({ assignments: { ...assignments.value } })
checkResult.value = response?.data || response
stepOnePassed.value = Boolean(checkResult.value?.allCorrect)
await persist(stepOnePassed.value ? 2 : 1)
if (stepOnePassed.value) proxy?.$modal?.msgSuccess("匹配正确,已解锁第 2 步")
} catch (error) {
checkResult.value = { allCorrect: false, message: error?.message || "判题失败,请稍后重试" }
} finally { checking.value = false }
}
function fillExample(row) { stepTwoContents.value = { ...stepTwoContents.value, [row.id]: row.example } }
async function saveStepTwo() {
if (saving.value) return
saving.value = true
try { await persist(2); proxy?.$modal?.msgSuccess("PRD 详细信息已保存") } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试") } finally { saving.value = false }
}
async function goToStepThree() {
if (saving.value) return
saving.value = true
try { await persist(2); currentStep.value = 3 } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试") } finally { saving.value = false }
}
function triggerDocumentUpload() { if (!documentUploading.value) documentInput.value?.click() }
function formatFileSize(size) {
const value = Number(size || 0)
if (!value) return "已上传"
return value < 1024 * 1024 ? `${Math.max(1, Math.round(value / 1024))} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`
}
async function handleDocumentUpload(event) {
const file = event.target.files?.[0]
if (!file) return
if (!/\.(docx|pdf)$/i.test(file.name)) { proxy?.$modal?.msgWarning("请上传 .docx 或 .pdf 格式的产品需求文档"); event.target.value = ""; return }
if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgWarning("产品需求文档大小不能超过 20MB"); event.target.value = ""; return }
documentUploading.value = true
try {
const previewData = new FormData(); previewData.append("file", file)
const uploadData = new FormData(); uploadData.append("file", file)
const previewResponse = await previewRequirementsDocumentFile(previewData)
const preview = previewResponse?.data || previewResponse || {}
let url = ""
if (isStudentDemo()) url = URL.createObjectURL(file)
else {
const uploadResponse = await uploadStudentTrainingFile(uploadData)
url = uploadResponse?.url || uploadResponse?.fileName || uploadResponse?.data?.url || uploadResponse?.data?.fileName || ""
}
if (!url) throw new Error("未获取到文件地址,请重新上传")
stepThreeDocument.value = { name: file.name, url, fileType: String(preview.fileType || (file.name.toLowerCase().endsWith(".pdf") ? "PDF" : "DOCX")).toUpperCase(), size: file.size, previewContent: String(preview.content || "") }
await persist(3)
showDocumentPreview.value = true
proxy?.$modal?.msgSuccess("产品需求文档已上传并保存")
} catch (error) { proxy?.$modal?.msgError(error?.message || "文件上传或解析失败,请稍后重试") }
finally { documentUploading.value = false; event.target.value = "" }
}
async function removeDocument() {
stepThreeDocument.value = { name: "", url: "", fileType: "", size: 0, previewContent: "" }
showDocumentPreview.value = false
try { await persist(3); proxy?.$modal?.msgSuccess("已删除产品需求文档") }
catch (error) { proxy?.$modal?.msgError(error?.message || "删除状态保存失败,请稍后重试") }
}
async function saveStepThree() {
if (!isDocumentReady.value || saving.value) return
saving.value = true
try { await persist(3); proxy?.$modal?.msgSuccess("产品需求文档已保存") } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试") } finally { saving.value = false }
}
async function submitTask() {
if (!canSubmitTask.value || saving.value || submitting.value || documentUploading.value) return
submitting.value = true
try { await saveStudentTrainingAnswer(TASK_KEY, buildSubmitPayload()); proxy?.$modal?.msgSuccess("产品需求文档已提交") }
catch (error) { proxy?.$modal?.msgError(error?.message || "提交失败,请稍后重试") }
finally { submitting.value = false }
}
async function exportStepTwoTable() {
try {
const XLSX = await import("xlsx")
const header = ["序号", "模块", "子模块", "填写内容"]
const rows = []
PRD_DETAIL_MODULES.forEach((module) => module.rows.forEach((row, index) => {
const reference = row.example ? `示例参考:${row.example}` : "示例参考:—"
const written = stepTwoContents.value[row.id]?.trim() || "(未填写)"
rows.push([index === 0 ? module.order : "", index === 0 ? module.name : "", row.name, `${reference}\n\n学生填写:${written}`])
}))
const sheet = XLSX.utils.aoa_to_sheet([header, ...rows])
sheet["!cols"] = [{ wch: 8 }, { wch: 18 }, { wch: 28 }, { wch: 88 }]
sheet["!rows"] = [{ hpt: 26 }, ...rows.map(() => ({ hpt: 78 }))]
sheet["!merges"] = []
let offset = 1
PRD_DETAIL_MODULES.forEach((module) => { if (module.rows.length > 1) { sheet["!merges"].push({ s: { r: offset, c: 0 }, e: { r: offset + module.rows.length - 1, c: 0 } }, { s: { r: offset, c: 1 }, e: { r: offset + module.rows.length - 1, c: 1 } }) } offset += module.rows.length })
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, "PRD详细信息")
XLSX.writeFile(workbook, "PRD详细信息表.xlsx")
proxy?.$modal?.msgSuccess("PRD 表格已导出,示例内容已一并保留")
} catch (error) { proxy?.$modal?.msgError(error?.message || "导出失败,请稍后重试") }
}
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null } catch (_) { taskConfig.value = null } }
async function loadSavedProgress() {
try {
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data
const savedStepOne = parseSavedAnswer(answer?.step1Answer)
const normalized = normalizeAssignments(savedStepOne?.assignments)
if (normalized) assignments.value = normalized
stepOnePassed.value = Boolean(savedStepOne?.passed)
checkResult.value = savedStepOne?.checkResult || null
stepTwoContents.value = normalizeStepTwoContents(parseSavedAnswer(answer?.step2Answer))
const savedStepThree = normalizeStepThreeDocument(parseSavedAnswer(answer?.step3Answer))
if (savedStepThree) stepThreeDocument.value = savedStepThree
if (stepOnePassed.value && Number(answer?.currentStep || 1) >= 3) currentStep.value = 3
else if (stepOnePassed.value && Number(answer?.currentStep || 1) >= 2) currentStep.value = 2
} catch (_) { /* 首次进入时保留空白答题区 */ }
}
function switchStep(step) {
if (step === 1) { currentStep.value = 1; return }
if (step === 2) {
if (!stepOnePassed.value) { proxy?.$modal?.msgInfo("请先完成第 1 步匹配并通过判题。"); return }
currentStep.value = 2
return
}
if (step === 3) {
if (!stepOnePassed.value) { proxy?.$modal?.msgInfo("请先完成第 1 步匹配并通过判题。"); return }
currentStep.value = 3
return
}
proxy?.$modal?.msgInfo("请先完成当前步骤后再进入后续实训。")
}
function goNextStep() { switchStep(2) }
function saveCurrentProgress() {
if (currentStep.value === 1) {
if (allAssigned.value) return checkAndSave()
return saveCurrentMatching()
}
if (currentStep.value === 2) return saveStepTwo()
return saveStepThree()
}
function previousStep() { if (currentStep.value > 1) currentStep.value -= 1 }
async function nextStep() {
if (currentStep.value >= IMPLEMENTED_STEP_COUNT) return
if (currentStep.value === 1) { goNextStep(); return }
await goToStepThree()
}
function exportOutcome() { if (currentStep.value === 2) { exportStepTwoTable() } else if (currentStep.value === 3 && isDocumentReady.value) { showDocumentPreview.value = true } else { proxy?.$modal?.msgInfo("请进入第 2 步导出 PRD 详细信息表。") } }
</script>
<style lang="scss" scoped>
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.requirements-doc-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;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 核心容器:只设 gap,无边框/背景/阴影 */
.requirements-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
margin: 0 auto;
width: min(1480px, calc(100% - 48px));
padding: 20px 0 60px;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header:白卡 + 左侧教育蓝竖条 ===== */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px 16px 16px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
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;
flex: 1 1 auto;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.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;
color: var(--student-accent-deep);
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--student-panel-border);
b {
color: var(--student-text);
font-size: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
.requirements-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* ===== task-card / step-card 透明化 ===== */
.task-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.section-eyebrow {
margin: 0;
color: var(--student-accent);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.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 #bfdbfe;
border-radius: 999px;
padding: 4px 10px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 600;
white-space: nowrap;
}
.step-desc {
margin: 14px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* ===== 引导区:教育蓝风(左 3px 教育蓝边 + 浅蓝底) ===== */
.matching-tip,
.prd-note,
.prd-delivery-guide {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.matching-tip,
.prd-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 14px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
}
.matching-tip .el-icon,
.prd-note .el-icon {
flex: 0 0 auto;
margin-top: 2px;
color: var(--student-accent);
}
/* ===== 匹配工作台 ===== */
.matching-workspace {
display: grid;
grid-template-columns: minmax(0, 1.22fr) minmax(350px, 0.78fr);
gap: 24px;
align-items: start;
}
.module-list {
display: grid;
gap: 10px;
}
.module-row {
display: grid;
grid-template-columns: minmax(185px, 0.31fr) minmax(0, 0.69fr);
min-height: 84px;
overflow: hidden;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
cursor: pointer;
}
.module-row--over {
border-color: var(--student-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.module-row--filled {
border-color: rgba(37, 99, 235, 0.4);
}
.module-label {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-right: 1px solid var(--student-panel-border);
color: var(--student-text);
background: var(--student-panel-bg);
}
.module-label span {
display: grid;
width: 27px;
height: 27px;
place-items: center;
border-radius: 50%;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 700;
}
.module-drop-zone {
display: flex;
align-items: center;
padding: 10px 12px;
}
.drop-placeholder {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
}
/* 待匹配卡片库 */
.purpose-bank {
position: sticky;
top: 22px;
padding: 18px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: #ffffff;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.purpose-bank--over {
border-color: var(--student-danger);
box-shadow: 0 0 0 3px rgba(217, 85, 76, 0.1);
}
.purpose-bank__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.purpose-bank__head h4 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.purpose-bank__head strong {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--student-panel-border);
border-radius: 50%;
color: var(--student-accent);
font-weight: 700;
background: var(--student-accent-soft);
}
.purpose-bank>p {
margin: 12px 0 15px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.65;
}
.purpose-card-list {
display: grid;
gap: 9px;
max-height: 570px;
overflow: auto;
padding-right: 3px;
}
.purpose-card {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 11px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
text-align: left;
line-height: 1.55;
cursor: grab;
transition: border-color 0.18s ease, background 0.18s ease;
}
.purpose-card:hover,
.purpose-card.selected {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
.purpose-card.dragging {
opacity: 0.5;
}
.purpose-card--assigned {
cursor: grab;
}
.purpose-card--assigned span {
flex: 1;
}
.purpose-card--assigned button {
display: inline-grid;
width: 26px;
height: 26px;
flex: 0 0 26px;
place-items: center;
border: 0;
border-radius: 50%;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
cursor: pointer;
}
.purpose-card--assigned button:hover {
color: #ffffff;
background: var(--student-danger);
}
.drag-grip {
color: var(--student-text-secondary);
letter-spacing: -3px;
}
.purpose-bank__empty {
padding: 22px 8px;
color: var(--student-text-secondary);
text-align: center;
}
.result-message {
margin: 10px 0 0;
padding: 10px 14px;
border: 1px solid rgba(180, 83, 9, 0.3);
border-radius: 8px;
color: #92400e;
background: rgba(180, 83, 9, 0.08);
font-size: var(--student-fs-body-sm);
}
.result-message.success {
border-color: rgba(74, 157, 110, 0.4);
color: #065f46;
background: rgba(74, 157, 110, 0.08);
}
/* ===== 按钮区 ===== */
.task-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
/* ===== 表格 ===== */
.prd-table-wrap {
overflow: auto;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
.prd-detail-table {
width: 100%;
min-width: 1000px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.prd-detail-table th {
padding: 10px 12px;
color: var(--student-text);
background: #fafbfc;
font-weight: 800;
font-size: var(--student-fs-body-sm);
text-align: left;
}
.prd-detail-table td {
padding: 10px 12px;
border-top: 1px solid var(--student-panel-border);
border-right: 1px solid var(--student-panel-border);
vertical-align: top;
background: transparent;
color: var(--student-text);
}
.prd-detail-table th:not(:last-child) {
border-right: 1px solid var(--student-panel-border);
}
.prd-detail-table td:last-child {
border-right: 0;
}
.prd-detail-table th:nth-child(1),
.module-number {
width: 72px;
text-align: center;
}
.prd-detail-table th:nth-child(2),
.module-name {
width: 155px;
}
.prd-detail-table th:nth-child(3),
.submodule-name {
width: 245px;
}
.module-number,
.module-name,
.submodule-name {
color: var(--student-text);
vertical-align: middle !important;
font-size: var(--student-fs-body-sm);
}
.module-name,
.submodule-name {
background: var(--student-panel-bg) !important;
font-weight: 700;
}
.detail-editor-cell {
min-width: 480px;
}
.example-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.example-bar button {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 6px;
color: var(--student-accent);
background: #ffffff;
font-size: var(--student-fs-caption);
font-weight: 600;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
}
.example-bar button:hover {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
}
/* textarea 透明底 + outline focus */
.detail-editor-cell textarea {
display: block;
width: 100%;
min-height: 92px;
resize: vertical;
box-sizing: border-box;
padding: 8px 10px;
border: 0;
border-radius: 0;
outline: none;
color: var(--student-text);
background: transparent;
font: inherit;
font-size: var(--student-fs-body);
line-height: 1.55;
transition: outline 0.16s ease;
}
.detail-editor-cell textarea:focus {
outline: 2px solid rgba(31, 41, 55, 0.2);
outline-offset: -2px;
}
.detail-editor-cell textarea::placeholder {
color: var(--student-text-secondary);
}
/* ===== 文档交付区 ===== */
.prd-delivery-guide {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
margin: 0;
padding: 14px 16px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
font-size: var(--student-fs-body);
}
.prd-delivery-guide ol {
margin: 0;
padding-left: 20px;
color: var(--student-text-secondary);
line-height: 1.6;
}
.prd-delivery-guide span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
text-align: right;
}
.file-input-hidden {
display: none;
}
.document-drop-zone {
display: grid;
min-height: 220px;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
margin-top: 14px;
border: 1.5px dashed var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
text-align: center;
transition: border-color 0.16s ease, background-color 0.16s ease, transform 0.16s ease;
cursor: pointer;
}
.document-drop-zone:hover {
border-color: var(--student-accent);
background-color: var(--student-panel-bg);
transform: translateY(-1px);
}
.document-drop-zone__icon {
font-size: 36px;
color: var(--student-accent);
}
.document-drop-zone strong {
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
.document-drop-zone span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.uploaded-document-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
margin-top: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
.uploaded-document-icon {
flex: 0 0 auto;
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
background: var(--student-accent-soft);
color: var(--student-accent);
font-size: 22px;
}
.uploaded-document-main {
min-width: 0;
flex: 1;
}
.uploaded-document-main strong {
display: block;
overflow: hidden;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.uploaded-document-main span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.uploaded-document-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 8px;
}
.upcoming-card {
text-align: center;
padding: 30px 20px;
}
.upcoming-card h3 {
margin: 8px 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
.upcoming-card p:not(.section-eyebrow) {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
}
/* ===== 预览弹窗 ===== */
.prd-pdf-preview {
height: min(68vh, 780px);
overflow: hidden;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.prd-pdf-preview iframe {
width: 100%;
height: 100%;
border: 0;
}
.prd-text-preview {
max-height: min(68vh, 780px);
overflow: auto;
padding: 20px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font-family: var(--student-font-base);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.prd-preview-empty {
padding: 40px;
text-align: center;
color: var(--student-text-secondary);
}
/* ===== scoped 内按钮基础(教育蓝极简,40px/10px) ===== */
.outline-action,
.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-accent);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 700;
text-shadow: none;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}
.outline-action:hover:not(:disabled),
.btn-nav:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
.btn-submit {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
}
.btn-submit:hover:not(:disabled) {
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
color: #ffffff;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
.btn-danger {
border: 1px solid rgba(239, 68, 68, 0.4);
color: var(--student-danger);
background: #ffffff;
}
.btn-danger:hover:not(:disabled) {
border-color: var(--student-danger);
background: rgba(239, 68, 68, 0.08);
color: var(--student-danger);
}
/* ===== 响应式 ===== */
@media (max-width: 1020px) {
.matching-workspace {
grid-template-columns: 1fr;
}
.purpose-bank {
position: static;
}
.purpose-card-list {
max-height: 360px;
}
}
@media (max-width: 680px) {
.requirements-core {
width: min(100% - 24px, 1480px);
padding-top: 16px;
}
.task-header {
flex-direction: column;
align-items: flex-start;
}
.requirements-toolbar {
width: 100%;
}
.prd-delivery-guide {
grid-template-columns: 1fr;
}
.prd-delivery-guide span {
text-align: left;
}
.module-row {
grid-template-columns: 1fr;
}
.module-label {
border-right: 0;
border-bottom: 1px solid var(--student-panel-border);
}
.task-actions {
width: 100%;
}
.outline-action,
.btn-nav {
flex: 1;
}
.purpose-bank {
padding: 14px;
}
.example-bar {
flex-direction: column;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
width: 100%;
}
.btn-nav {
width: 100%;
}
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
/* ===== 底部步骤导航按钮 ===== */
.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-button);
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: 18px;
}
&: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-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&: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-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav: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;
}
</style>
<style lang="scss">
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝主题)
* 覆盖全局 !important 规则
* ============================================================ */
#app .studentLayout section.app-main:has(.requirements-doc-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: 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 {
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-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !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: 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;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 2px !important;
min-width: 0 !important;
}
.step-copy small {
font-size: var(--student-fs-caption) !important;
color: #9ca3af !important;
font-weight: 400 !important;
}
/* 未到达 hover */
&: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;
}
.step-copy small {
color: rgba(255, 255, 255, 0.65) !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ===== 窄屏:横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.requirements-doc-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.requirements-doc-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.requirements-doc-page .training-material-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 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
box-shadow: none !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
.el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !important;
}
&:hover,
&:focus {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
/* ============================================================
* 顶部导出按钮(outline-action)→ 教育蓝描边
* ============================================================ */
#app .studentLayout section.app-main:has(.requirements-doc-page) .outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
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;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
box-shadow: none !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
.el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !important;
}
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: translateY(-1px) !important;
}
}
</style>