diff --git a/docs/superpowers/specs/2026-08-31-requirements-doc-submit-design.md b/docs/superpowers/specs/2026-08-31-requirements-doc-submit-design.md
deleted file mode 100644
index ba593ee..0000000
--- a/docs/superpowers/specs/2026-08-31-requirements-doc-submit-design.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# 产品需求说明书任务提交设计
-
-## 背景
-
-实训任务15“产品需求说明书”已支持完成第1步匹配、填写第2步明细、上传并预览第3步 Word/PDF 文档,但第3步没有最终“提交任务”操作。统一顶部工具栏会查找页面中的同名按钮;找不到时仅提示学生先完成全部步骤,因此学生无法将该任务标记为已提交。
-
-## 目标
-
-让学生在完成第1步并上传有效 PRD 文档后,可在第3步明确提交任务;提交结果须复用现有学生答案保存接口,并保存为最终提交状态。
-
-## 范围
-
-- 在 `requirements-doc.vue` 的第3步操作区新增“提交任务”按钮。
-- 新增一个任务专用的最终提交处理函数。
-- 复用已有 `saveStudentTrainingAnswer` 接口;不新增或变更后端接口。
-- 添加静态回归测试,锁定按钮、前置条件与请求载荷。
-
-不修改通用工具栏的按钮匹配逻辑,不改动其他实训页面,也不增加第2步的额外必填校验。
-
-## 交互与数据流
-
-1. 学生完成第1步匹配,页面状态 `stepOnePassed` 为真。
-2. 学生上传可用的 `.docx` 或 `.pdf`,页面状态 `isDocumentReady` 为真。
-3. 第3步显示“提交任务”按钮;任一前置条件不满足时按钮禁用。
-4. 点击后,页面用当前三步答案构造一次保存请求:`currentStep: 3`、`submitted: true`、`saveAction: "SUBMIT"`。
-5. 请求成功后展示成功提示;请求失败时保留现有文档和表单状态,并展示中文错误提示。
-
-统一顶部工具栏通过其既有的可见按钮发现逻辑,能点击页面内新增的“提交任务”,从而与页面底部操作保持一致。
-
-## 错误处理
-
-- 处于提交中时禁用第3步保存与提交按钮,避免重复提交。
-- 文档未就绪或第1步未通过时不发起请求。
-- 后端错误优先展示返回的错误消息,缺失时使用“提交失败,请稍后重试”。
-
-## 测试
-
-新增 Node 静态测试,验证:
-
-- 页面第3步包含“提交任务”按钮并使用提交状态禁用条件;
-- 最终提交构造的载荷包含 `submitted: true`、`saveAction: "SUBMIT"` 与第3步进度;
-- 提交函数复用 `saveStudentTrainingAnswer` 并反馈成功/失败信息。
-
-执行新增测试、全量 `node --test tests` 和生产构建进行验证。
diff --git a/src/views/product/requirements-doc.vue b/src/views/product/requirements-doc.vue
index 319d4cc..c7a5e95 100644
--- a/src/views/product/requirements-doc.vue
+++ b/src/views/product/requirements-doc.vue
@@ -171,8 +171,9 @@
-
+
+
@@ -293,6 +294,7 @@ 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);
@@ -305,6 +307,7 @@ const progressItems = computed(() => trainingSteps.value.map((text, index) => ({
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
@@ -382,6 +385,7 @@ function buildStepThreeAnswer() { return { document: { ...stepThreeDocument.valu
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;
@@ -454,6 +458,13 @@ async function saveStepThree() {
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");
diff --git a/tests/requirements-doc-submit.static.test.cjs b/tests/requirements-doc-submit.static.test.cjs
new file mode 100644
index 0000000..453feda
--- /dev/null
+++ b/tests/requirements-doc-submit.static.test.cjs
@@ -0,0 +1,20 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+const assert = require("node:assert/strict");
+
+const page = fs.readFileSync(path.join(__dirname, "../src/views/product/requirements-doc.vue"), "utf8");
+
+test("产品需求说明书在已完成匹配且文档就绪时可以提交任务", () => {
+ assert.match(page, /const canSubmitTask = computed\(\(\) => stepOnePassed\.value && isDocumentReady\.value\);/);
+ assert.match(page, /@click="submitTask"/);
+ assert.match(page, /提交任务/);
+ assert.match(page, /:disabled="!canSubmitTask \|\| saving \|\| submitting \|\| documentUploading"/);
+});
+
+test("产品需求说明书最终提交保存完整答案并标记为已提交", () => {
+ assert.match(page, /function buildSubmitPayload\(\) \{ return \{ \.\.\.buildSavePayload\(3\), submitted: true, saveAction: "SUBMIT" \}; \}/);
+ assert.match(page, /async function submitTask\(\)[\s\S]*?await saveStudentTrainingAnswer\(TASK_KEY, buildSubmitPayload\(\)\)/);
+ assert.match(page, /产品需求文档已提交/);
+ assert.match(page, /提交失败,请稍后重试/);
+});