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

539 lines
48 KiB
Vue

<template>
<div class="requirements-doc-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge"><el-icon><Document /></el-icon> 产品规划与设计</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<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 class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>{{ currentStep === 3 ? '预览产品需求说明书' : '导出实训成果' }}</button>
</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><small>{{ stepStateText(index) }}</small></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>
<button type="button" class="btn-nav btn-primary" :disabled="!canSubmitTask || saving || submitting || documentUploading" @click="submitTask">{{ submitting ? '提交中…' : '提交任务' }}</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 TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.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 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 scoped>
.requirements-doc-page { min-height: 100vh; color: #dcebf8; background: radial-gradient(circle at 15% -10%, rgba(23, 134, 205, .22), transparent 36%), #071521; }
.training-core { width: min(1480px, calc(100% - 48px)); margin: 0 auto; padding: 30px 0 60px; }
.task-header { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.task-badge, .step-chip { display: inline-flex; align-items: center; gap: 7px; padding: 7px 12px; border: 1px solid rgba(68, 203, 255, .42); border-radius: 999px; color: #8ee7ff; background: rgba(6, 86, 129, .28); font-size: 13px; }.task-title { margin: 0; color: #f1f8ff; font-size: 26px; letter-spacing: .02em; }
.task-card { padding: 26px; border: 1px solid rgba(66, 181, 237, .42); border-radius: 24px; background: rgba(4, 24, 39, .78); box-shadow: 0 18px 42px rgba(0, 0, 0, .2); }.workbench-head, .step-title-row, .purpose-bank__head, .task-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.workbench-head h2, .step-title-row h3, .purpose-bank h4 { margin: 4px 0 0; color: #f2f9ff; }.section-eyebrow { margin: 0; color: #55d8ff; font-size: 12px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.workbench-actions, .task-actions { display: flex; flex-wrap: wrap; }.outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 0 15px; border: 1px solid rgba(98, 208, 255, .48); border-radius: 9px; color: #dff6ff; background: rgba(10, 87, 126, .35); cursor: pointer; transition: .2s ease; }.outline-action:hover, .btn-nav:hover:not(:disabled) { border-color: #56dcff; background: rgba(18, 137, 190, .48); transform: translateY(-1px); }.btn-submit { border-color: #26c9ed; color: #fff; background: linear-gradient(135deg, #087baa, #18b7df); }.btn-primary { border-color: #8a72ff; background: linear-gradient(135deg, #5a4fc9, #8c6ef1); }.btn-nav:disabled { cursor: not-allowed; opacity: .48; }
.step-indicator { display: flex; align-items: stretch; margin: 26px 0; padding: 10px; overflow-x: auto; border: 1px solid rgba(58, 170, 220, .35); border-radius: 18px; background: rgba(0, 15, 28, .45); }.step-tab { display: flex; min-width: 185px; flex: 1; align-items: center; gap: 10px; padding: 10px 12px; border: 0; border-radius: 12px; color: #8baabd; background: transparent; text-align: left; cursor: pointer; }.step-tab.active { color: #fff; background: linear-gradient(90deg, #087eb3, #12a7d5); box-shadow: inset 0 0 0 1px rgba(157, 239, 255, .55); }.step-tab.complete .step-marker { border-color: #53f1d5; color: #53f1d5; }.step-marker { display: inline-grid; width: 33px; height: 33px; flex: 0 0 33px; place-items: center; border: 1px solid #4c91b5; border-radius: 50%; font-weight: 700; }.step-copy { display: grid; gap: 3px; }.step-copy small { font-size: 11px; }.step-connector { width: 22px; min-width: 22px; align-self: center; height: 1px; background: #287596; }
.step-card { padding: 25px; border: 1px solid rgba(50, 162, 208, .42); border-radius: 18px; background: rgba(2, 18, 30, .58); }.step-desc { margin: 18px 0; color: #c0d2df; line-height: 1.8; }.matching-tip, .prd-note { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 20px; padding: 10px 13px; border-left: 3px solid #2ed4f0; border-radius: 5px; color: #a9ddeb; background: rgba(28, 122, 157, .15); font-size: 14px; line-height: 1.65; }
.matching-workspace { display: grid; grid-template-columns: minmax(0, 1.22fr) minmax(350px, .78fr); gap: 24px; align-items: start; }.module-list { display: grid; gap: 10px; }.module-row { display: grid; grid-template-columns: minmax(185px, .31fr) minmax(0, .69fr); min-height: 84px; overflow: hidden; border: 1px solid rgba(124, 166, 189, .45); border-radius: 10px; background: rgba(11, 39, 56, .48); transition: .18s ease; cursor: pointer; }.module-row--over { border-color: #48e3ff; box-shadow: 0 0 0 3px rgba(55, 215, 248, .16); }.module-row--filled { border-color: rgba(74, 220, 182, .56); }.module-label { display: flex; align-items: center; gap: 12px; padding: 16px; border-right: 1px solid rgba(124, 166, 189, .35); color: #eef9ff; background: rgba(13, 81, 113, .28); }.module-label span { display: grid; width: 27px; height: 27px; place-items: center; border-radius: 50%; color: #7ee6ff; background: rgba(11, 172, 214, .16); font-size: 12px; }.module-drop-zone { display: flex; align-items: center; padding: 10px 12px; }.drop-placeholder { color: #7696a8; font-size: 14px; }
.purpose-bank { position: sticky; top: 22px; padding: 18px; border: 1px solid rgba(66, 178, 226, .45); border-radius: 14px; background: linear-gradient(180deg, rgba(10, 71, 100, .38), rgba(7, 29, 45, .72)); transition: .18s ease; }.purpose-bank--over { border-color: #ffba67; box-shadow: 0 0 0 3px rgba(255, 186, 103, .16); }.purpose-bank__head strong { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(97, 222, 255, .58); border-radius: 50%; color: #8eeaff; }.purpose-bank > p { margin: 12px 0 15px; color: #9cb8c7; font-size: 13px; 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 rgba(111, 158, 179, .5); border-radius: 8px; color: #e4f4fb; background: rgba(7, 31, 47, .72); text-align: left; line-height: 1.55; cursor: grab; transition: .18s ease; }.purpose-card:hover, .purpose-card.selected { border-color: #53ddfb; background: rgba(14, 116, 153, .43); }.purpose-card.dragging { opacity: .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: #b9ebfa; background: rgba(104, 196, 224, .17); cursor: pointer; }.purpose-card--assigned button:hover { color: #fff; background: #bd5068; }.drag-grip { color: #5fd5ed; letter-spacing: -3px; }.purpose-bank__empty { padding: 22px 8px; color: #82b3c8; text-align: center; }.result-message { margin: 20px 0 0; padding: 11px 14px; border: 1px solid rgba(255, 168, 112, .52); border-radius: 8px; color: #ffd5ad; background: rgba(157, 77, 35, .14); }.result-message.success { border-color: rgba(84, 230, 187, .56); color: #a6f4d8; background: rgba(16, 134, 99, .16); }.task-actions { justify-content: flex-end; margin-top: 21px; }
.prd-detail-card { overflow: hidden; }.prd-table-wrap { overflow: auto; border: 1px solid rgba(102, 184, 223, .5); border-radius: 12px; }.prd-detail-table { width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }.prd-detail-table th { padding: 13px; color: #effbff; background: linear-gradient(90deg, #087da9, #0b9bca); font-weight: 700; }.prd-detail-table td { padding: 12px; border-top: 1px solid rgba(115, 165, 188, .38); border-right: 1px solid rgba(115, 165, 188, .38); vertical-align: top; background: rgba(9, 38, 57, .44); }.prd-detail-table th:not(:last-child) { border-right: 1px solid rgba(212, 246, 255, .25); }.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: #dff6ff; vertical-align: middle !important; }.module-name { background: rgba(10, 90, 122, .28) !important; font-weight: 700; }.submodule-name { background: rgba(20, 69, 92, .28) !important; }.detail-editor-cell { min-width: 480px; }.example-bar { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 9px; color: #7fa6ba; font-size: 13px; line-height: 1.6; }.example-bar button { flex: 0 0 auto; padding: 4px 8px; border: 1px solid rgba(96, 201, 237, .5); border-radius: 6px; color: #9deaff; background: rgba(5, 86, 124, .24); cursor: pointer; }.example-bar button:hover { color: #fff; background: rgba(12, 139, 182, .6); }.detail-editor-cell textarea { display: block; width: 100%; min-height: 92px; resize: vertical; box-sizing: border-box; padding: 10px; border: 1px solid rgba(108, 182, 215, .36); border-radius: 8px; outline: none; color: #eaf8ff; background: rgba(1, 18, 29, .62); font: inherit; line-height: 1.65; }.detail-editor-cell textarea:focus { border-color: #3ddff9; box-shadow: 0 0 0 3px rgba(55, 215, 248, .12); }.detail-editor-cell textarea::placeholder { color: #66899c; }.upcoming-card { text-align: center; }.upcoming-card h3 { margin: 8px 0; }.upcoming-card p:not(.section-eyebrow) { color: #a6c2d2; }
.file-input-hidden { display: none; }.prd-upload-card { overflow: hidden; }.prd-delivery-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 22px; align-items: center; padding: 20px 22px; border: 1px solid rgba(72, 201, 255, .2); border-radius: 16px; background: linear-gradient(135deg, rgba(20, 86, 127, .28), rgba(8, 32, 54, .2)); }.prd-delivery-guide h3 { margin: 0 0 10px; font-size: 18px; color: #eef9ff; }.prd-delivery-guide ol { margin: 0; padding-left: 20px; color: #b8d3e2; line-height: 1.8; }.document-drop-zone { display: grid; min-height: 270px; place-content: center; justify-items: center; gap: 14px; padding: 30px; margin-top: 20px; border: 1px dashed rgba(81, 207, 255, .5); border-radius: 18px; background: radial-gradient(circle at 50% 0, rgba(41, 180, 235, .16), transparent 52%), rgba(8, 33, 52, .56); text-align: center; transition: .2s ease; }.document-drop-zone:hover { border-color: #4ad4ff; background-color: rgba(14, 65, 95, .66); transform: translateY(-2px); }.document-upload-icon { font-size: 42px; color: #42d8ff; }.document-drop-zone h3 { margin: 0; color: #eefaff; font-size: 21px; }.document-drop-zone p { max-width: 560px; margin: 0; color: #a6c2d2; line-height: 1.7; }.uploaded-document-card { display: flex; align-items: center; gap: 16px; padding: 18px 20px; margin-top: 20px; border: 1px solid rgba(69, 206, 255, .3); border-radius: 16px; background: rgba(9, 45, 70, .72); }.document-file-icon { flex: 0 0 auto; font-size: 32px; color: #55dcff; }.uploaded-document-main { min-width: 0; flex: 1; }.uploaded-document-main h3 { overflow: hidden; margin: 0 0 5px; color: #f0fbff; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }.uploaded-document-main p { margin: 0; color: #9cc1d4; font-size: 13px; }.uploaded-document-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 9px; }.btn-danger { border: 1px solid rgba(255, 115, 127, .45); color: #ffacb3; background: rgba(116, 32, 48, .25); }.btn-danger:hover { border-color: #ff7884; background: rgba(150, 38, 55, .38); color: #fff; }.prd-pdf-preview { height: min(68vh, 780px); overflow: hidden; border: 1px solid #d8e4ea; border-radius: 10px; background: #fff; }.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 #d8e4ea; border-radius: 10px; color: #263849; background: #f8fbfd; font-family: "Microsoft YaHei", sans-serif; line-height: 1.8; white-space: pre-wrap; word-break: break-word; }
@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) { .training-core { width: min(100% - 24px, 1480px); padding-top: 16px; }.task-card, .step-card { padding: 15px; }.module-row { grid-template-columns: 1fr; }.module-label { border-right: 0; border-bottom: 1px solid rgba(124, 166, 189, .35); }.task-header { align-items: flex-start; flex-direction: column; }.task-title { font-size: 22px; }.step-indicator { margin: 18px 0; }.step-tab { min-width: 165px; }.workbench-actions, .task-actions { width: 100%; }.outline-action, .btn-nav { flex: 1; }.purpose-bank { padding: 14px; }.example-bar { flex-direction: column; } }
</style>