|
|
<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">
|
|
|
<div class="workbench-head">
|
|
|
<div>
|
|
|
<p class="section-eyebrow">{{ currentStep === 3 ? 'PRD document delivery' : currentStep === 2 ? 'PRD detailed specification' : 'PRD module matching' }}</p>
|
|
|
<h2>{{ currentStep === 3 ? '生成并提交产品需求文档' : currentStep === 2 ? '填写 PRD 七大模块详细信息' : '认识 PRD 的核心模块' }}</h2>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<nav class="step-indicator" aria-label="产品需求文档撰写步骤">
|
|
|
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
|
|
|
<button
|
|
|
type="button"
|
|
|
:class="['step-tab', { active: currentStep === index + 1, complete: 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="saving" @click="saveCurrentMatching">保存当前匹配</button>
|
|
|
<button type="button" class="btn-nav btn-submit" :disabled="checking || !allAssigned" @click="checkAndSave">{{ checking ? '判题中…' : '检查匹配并保存' }}</button>
|
|
|
<button type="button" class="btn-nav" :disabled="checking || saving" @click="resetMatching"><el-icon><RefreshLeft /></el-icon>重新打乱</button>
|
|
|
<button type="button" class="btn-nav btn-primary" :disabled="!stepOnePassed" @click="goNextStep">进入第 2 步 <el-icon><ArrowRight /></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" :disabled="saving" @click="saveStepTwo">{{ saving ? '保存中…' : '保存详细信息' }}</button>
|
|
|
<button type="button" class="btn-nav btn-submit" @click="exportStepTwoTable"><el-icon><Download /></el-icon>导出 PRD 表格</button>
|
|
|
<button type="button" class="btn-nav btn-primary" :disabled="saving" @click="goToStepThree">进入第 3 步<el-icon><ArrowRight /></el-icon></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" :disabled="!isDocumentReady || saving || submitting || documentUploading" @click="saveStepThree">{{ saving ? '保存中…' : '保存文档' }}</button>
|
|
|
<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>
|
|
|
<button type="button" class="btn-nav" @click="switchStep(2)">返回第 2 步</button>
|
|
|
</section>
|
|
|
</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 { ArrowRight, 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 文档评审"];
|
|
|
const fallbackTask = {
|
|
|
title: "产品需求文档撰写",
|
|
|
background: "PRD 是连接产品、设计、研发和测试的纲领性文件。本实训先建立对 PRD 模块职责的清晰认识,再逐步完成需求文档撰写。",
|
|
|
goals: ["识别 PRD 的核心模块", "理解各模块在产品开发中的作用", "建立结构化撰写产品需求文档的基础"],
|
|
|
requirement: "完成 PRD 核心模块匹配及七大模块详细信息填写。",
|
|
|
};
|
|
|
|
|
|
const taskConfig = ref(null);
|
|
|
const briefOpen = ref(false);
|
|
|
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 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-fs-display: 17px;
|
|
|
--student-fs-h1: 15px;
|
|
|
--student-fs-h2: 14px;
|
|
|
--student-fs-h3: 13px;
|
|
|
--student-fs-body: 13px;
|
|
|
--student-fs-body-sm: 12px;
|
|
|
--student-fs-caption: 11px;
|
|
|
--student-fs-button: 13px;
|
|
|
--student-fs-button-lg: 13px;
|
|
|
--student-fs-data: 14px;
|
|
|
|
|
|
min-width: 0;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
/* 核心容器:只设 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: 12px;
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.task-brief-text {
|
|
|
display: -webkit-box;
|
|
|
overflow: hidden;
|
|
|
-webkit-box-orient: vertical;
|
|
|
-webkit-line-clamp: 1;
|
|
|
}
|
|
|
|
|
|
.task-brief-toggle {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
color: var(--student-accent-deep);
|
|
|
background: transparent;
|
|
|
font: inherit;
|
|
|
font-weight: 700;
|
|
|
cursor: pointer;
|
|
|
|
|
|
&:hover { text-decoration: underline; 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: 12px; }
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
.workbench-head {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
gap: 16px;
|
|
|
}
|
|
|
|
|
|
.workbench-head h2 {
|
|
|
margin: 4px 0 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h1);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.35;
|
|
|
}
|
|
|
|
|
|
.section-eyebrow {
|
|
|
margin: 0;
|
|
|
color: var(--student-accent);
|
|
|
font-size: 11px;
|
|
|
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: 11px;
|
|
|
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: 15px;
|
|
|
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,
|
|
|
.btn-primary {
|
|
|
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),
|
|
|
.btn-primary: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-save {
|
|
|
border: 1px solid #e5e7eb;
|
|
|
color: #374151;
|
|
|
background: #f3f4f6;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.btn-save:hover:not(:disabled) {
|
|
|
border-color: #d1d5db;
|
|
|
background: #e9ecef;
|
|
|
color: #374151;
|
|
|
box-shadow: none;
|
|
|
transform: none;
|
|
|
}
|
|
|
|
|
|
.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);
|
|
|
}
|
|
|
|
|
|
.btn-nav:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
/* ===== 响应式 ===== */
|
|
|
@media (max-width: 1020px) {
|
|
|
.matching-workspace { grid-template-columns: 1fr; }
|
|
|
.purpose-bank { position: static; }
|
|
|
.purpose-card-list { max-height: 360px; }
|
|
|
.workbench-head { align-items: flex-start; flex-direction: column; }
|
|
|
}
|
|
|
|
|
|
@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; }
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
}
|
|
|
</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 {
|
|
|
flex: 1 1 0 !important;
|
|
|
max-width: 240px !important;
|
|
|
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: 13px !important;
|
|
|
font-weight: 600 !important;
|
|
|
line-height: 1.3 !important;
|
|
|
white-space: normal !important;
|
|
|
text-align: center !important;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease !important;
|
|
|
|
|
|
&:first-child {
|
|
|
border-radius: 8px 0 0 8px !important;
|
|
|
}
|
|
|
|
|
|
&:last-child {
|
|
|
border-radius: 0 8px 8px 0 !important;
|
|
|
border-right: 1px solid #e5e7eb !important;
|
|
|
}
|
|
|
|
|
|
.step-marker {
|
|
|
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: 12px !important;
|
|
|
font-weight: 700 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition: all 0.16s ease !important;
|
|
|
}
|
|
|
|
|
|
.step-copy {
|
|
|
display: flex !important;
|
|
|
flex-direction: column !important;
|
|
|
gap: 2px !important;
|
|
|
min-width: 0 !important;
|
|
|
}
|
|
|
|
|
|
.step-copy small {
|
|
|
font-size: 11px !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: 13px !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: 14px !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: 13px !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: 14px !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>
|