merge: product development factors step two

dev-QQq
chenyuan 3 weeks ago
commit 741f87c8ee

@ -0,0 +1,256 @@
# 产品开发关键因素第 2 步 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为学生端产品开发关键因素的第 2 步提供技术与合规两行对比表、服务端四项非空校验,以及仅保存并进入第 3 步的流程。
**Architecture:** 前端将第 1 步和第 2 步的固定行数据分开维护,分别写入已有答案记录的 `step1Answer``step2Answer`;第 2 步校验成功后以 `SAVE` 写入 `currentStep: 3`。后端参照第 1 步新增独立 DTO、校验服务和控制器路由保留已有答案服务的字段合并行为不新增表或改动教师端。
**Tech Stack:** Vue 3 Composition API、Element Plus、Vite、Spring Boot、JUnit 5、Mockito、Maven。
## Global Constraints
- 仅修改学生端 `product-development-factors` 第 2 步和其校验接口;不改教师端、管理员端、数据库结构、材料下载或第 3 步业务内容。
- 第 2 步表格只能出现“技术应用”“合规与认证”两行及原型的四列表头,且不显示产品名称输入框。
- 四个分析值去除首尾空白后必须非空;服务端拒绝缺失、重复或不在白名单内的维度。
- 推进使用 `saveAction: "SAVE"``currentStep: 3`,不得提交整项任务或触发成绩结算。
- 仅提交本计划列出的文件,保留主工作区既有未跟踪文件。
---
## 文件结构
### 前端仓库 `E:/workspace/dianshang/e-commerce-internet`
- 修改:`src/views/foundation/product-development-factors.vue` — 同屏步骤状态分离、第 2 步表格、答案恢复与保存推进。
- 修改:`src/api/studentTrainingAnswer.js` — 第 2 步真实/Demo 校验封装。
- 修改:`tests/product-development-factors-step-one.static.test.cjs` — 扩展为覆盖第 1、2 步共同页面的静态契约。
### 后端仓库 `E:/workspace/dianshang/link_commerce`
- 新建:`entity/dto/ProductDevelopmentFactorsStepTwoValidationRequest.java` — 两行分析请求。
- 新建:`entity/dto/ProductDevelopmentFactorsStepTwoValidationResult.java` — 校验响应。
- 新建:`service/ProductDevelopmentFactorsStepTwoService.java` 与 `service/impl/ProductDevelopmentFactorsStepTwoServiceImpl.java` — 学生身份和二行四项文本校验。
- 修改:`controller/stu/StudentTrainingAnswerController.java` — 第 2 步校验路由。
- 新建:`service/impl/ProductDevelopmentFactorsStepTwoServiceImplTest.java` — 服务校验行为。
- 修改:`controller/stu/StudentTrainingAnswerControllerTest.java` — 控制器认证传递。
## Task 1: 实现第 2 步后端校验服务
**Files:**
- Create: `link_commerce/src/main/java/com/sztzjy/linkCommerce/entity/dto/ProductDevelopmentFactorsStepTwoValidationRequest.java`
- Create: `link_commerce/src/main/java/com/sztzjy/linkCommerce/entity/dto/ProductDevelopmentFactorsStepTwoValidationResult.java`
- Create: `link_commerce/src/main/java/com/sztzjy/linkCommerce/service/ProductDevelopmentFactorsStepTwoService.java`
- Create: `link_commerce/src/main/java/com/sztzjy/linkCommerce/service/impl/ProductDevelopmentFactorsStepTwoServiceImpl.java`
- Create: `link_commerce/src/test/java/com/sztzjy/linkCommerce/service/impl/ProductDevelopmentFactorsStepTwoServiceImplTest.java`
**Interfaces:**
- Consumes: `JwtUser`、`ServiceException`、`StringUtils` 和第 1 步服务的校验模式。
- Produces: `ProductDevelopmentFactorsStepTwoService#validate(ProductDevelopmentFactorsStepTwoValidationRequest request, JwtUser user)`,成功返回 `valid=true`、`message="校验通过"`。
- [ ] **Step 1: 写服务失败测试**
测试完整的“技术应用”“合规与认证”两行通过;将任一成功或失败文本设为空、替换成重复维度或未知维度、使用教师用户时均断言 `ServiceException`
```java
ProductDevelopmentFactorsStepTwoValidationResult result = service.validate(validRequest(), student());
assertTrue(result.isValid());
assertThrows(ServiceException.class, () -> service.validate(requestWithBlankAnalysis(), student()));
assertThrows(ServiceException.class, () -> service.validate(validRequest(), teacher()));
```
- [ ] **Step 2: 运行失败测试**
Run: `mvn -B '-Dtest=ProductDevelopmentFactorsStepTwoServiceImplTest' test`
Expected: FAIL因为第 2 步 DTO 和服务尚未存在。
- [ ] **Step 3: 写最小服务实现**
请求 DTO 定义 `List<Row> factors`,内嵌 `Row` 提供 `dimension`、`successAnalysis`、`failedAnalysis` getter/setter结果 DTO 提供 `boolean valid`、`String message` getter/setter。服务实现的唯一维度集合为
```java
private static final Set<String> DIMENSIONS = new LinkedHashSet<>(Arrays.asList(
"技术应用", "合规与认证"));
```
`validate` 先拒绝未登录和非 `roleId == 4` 的用户;随后要求恰好两行、每个维度属于集合且只出现一次、每行成功/失败文本均 `StringUtils.isNotBlank`,最后确认提交集合与 `DIMENSIONS` 完全相等。请求内容错误使用 `HttpStatus.BAD_REQUEST`,登录/权限错误分别使用 `UNAUTHORIZED`/`FORBIDDEN`。
- [ ] **Step 4: 运行服务测试**
Run: `mvn -B '-Dtest=ProductDevelopmentFactorsStepTwoServiceImplTest' test`
Expected: PASS所有新增服务行为通过。
- [ ] **Step 5: 提交服务变更**
```powershell
git add src/main/java/com/sztzjy/linkCommerce/entity/dto/ProductDevelopmentFactorsStepTwoValidationRequest.java src/main/java/com/sztzjy/linkCommerce/entity/dto/ProductDevelopmentFactorsStepTwoValidationResult.java src/main/java/com/sztzjy/linkCommerce/service/ProductDevelopmentFactorsStepTwoService.java src/main/java/com/sztzjy/linkCommerce/service/impl/ProductDevelopmentFactorsStepTwoServiceImpl.java src/test/java/com/sztzjy/linkCommerce/service/impl/ProductDevelopmentFactorsStepTwoServiceImplTest.java
git commit -m "feat: validate product factors step two"
```
## Task 2: 暴露第 2 步校验路由
**Files:**
- Modify: `link_commerce/src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerController.java`
- Modify: `link_commerce/src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerControllerTest.java`
**Interfaces:**
- Consumes: Task 1 的 `ProductDevelopmentFactorsStepTwoService#validate`
- Produces: `POST /api/student-training-answers/product-development-factors/step-2/validate`
- [ ] **Step 1: 写控制器失败测试**
mock 第 2 步服务,注入控制器,调用 `validateProductDevelopmentFactorsStepTwo(answer, authenticatedStudentRequest())`,断言 HTTP 200、响应对象不变且服务获得了认证学生。
```java
when(service.validate(any(ProductDevelopmentFactorsStepTwoValidationRequest.class), any(JwtUser.class))).thenReturn(validation);
assertEquals(HttpStatus.OK, result.getStatusCode());
verify(service).validate(any(ProductDevelopmentFactorsStepTwoValidationRequest.class), any(JwtUser.class));
```
- [ ] **Step 2: 运行控制器测试确认失败**
Run: `mvn -B '-Dtest=StudentTrainingAnswerControllerTest' test`
Expected: FAIL因为控制器还没有第 2 步方法。
- [ ] **Step 3: 新增路由**
添加 DTO/服务 import 与 `@Autowired ProductDevelopmentFactorsStepTwoService productDevelopmentFactorsStepTwoService`,并按第 1 步模式新增:
```java
@PostMapping("/product-development-factors/step-2/validate")
public ResultEntity<ProductDevelopmentFactorsStepTwoValidationResult> validateProductDevelopmentFactorsStepTwo(
@RequestBody ProductDevelopmentFactorsStepTwoValidationRequest answer, HttpServletRequest request) {
try {
return new ResultEntity<>(HttpStatus.OK, "校验通过",
productDevelopmentFactorsStepTwoService.validate(answer, TokenProvider.getJWTUser(request)));
} catch (ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
```
- [ ] **Step 4: 运行聚焦后端测试**
Run: `mvn -B '-Dtest=ProductDevelopmentFactorsStepOneServiceImplTest,ProductDevelopmentFactorsStepTwoServiceImplTest,StudentTrainingAnswerControllerTest' test`
Expected: PASS第 1 步回归、新服务和两条控制器路由均通过。
- [ ] **Step 5: 提交路由变更**
```powershell
git add src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerController.java src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerControllerTest.java
git commit -m "feat: expose product factors step two validation"
```
## Task 3: 实现学生端第 2 步页面与保存流程
**Files:**
- Modify: `e-commerce-internet/src/api/studentTrainingAnswer.js`
- Modify: `e-commerce-internet/src/views/foundation/product-development-factors.vue`
- Modify: `e-commerce-internet/tests/product-development-factors-step-one.static.test.cjs`
**Interfaces:**
- Consumes: Task 2 路由与已有 `saveStudentTrainingAnswer`
- Produces: `checkProductDevelopmentFactorsStepTwo(payload)`;保存 `{ step2Answer, currentStep: 3, submitted: false, saveAction: "SAVE" }` 并进入索引 2。
- [ ] **Step 1: 写前端静态契约失败测试**
扩展测试以断言 `STEP_TWO_FACTOR_ROWS`、两个中文维度、`checkProductDevelopmentFactorsStepTwo`、第 2 步路由、`v-else-if="currentStep === 1"`、`step2Answer: JSON.stringify(buildStepTwoOutcome())`、`buildStepTwoAnswerPayload("SAVE", 3)` 都存在;断言源码不出现 `buildStepTwoAnswerPayload("SUBMIT"`
```js
assert.match(page, /const STEP_TWO_FACTOR_ROWS = \[/);
assert.match(page, /await checkProductDevelopmentFactorsStepTwo\(buildStepTwoValidationPayload\(\)\)/);
assert.match(api, /product-development-factors\/step-2\/validate/);
```
- [ ] **Step 2: 运行静态测试确认失败**
Run: `node tests/product-development-factors-step-one.static.test.cjs`
Expected: FAIL因为第 2 步常量、API 和保存动作未实现。
- [ ] **Step 3: 添加 API 与 Demo 校验**
在 API 文件新增 `checkProductDevelopmentFactorsStepTwo(payload)`。Demo 模式只接受两行固定维度且四项分析非空,失败时拒绝 Promise真实模式调用
```js
return request({
url: "/api/student-training-answers/product-development-factors/step-2/validate",
method: "post",
data: payload,
headers: { repeatSubmit: false },
});
```
- [ ] **Step 4: 分离两步数据并渲染第 2 步**
将现有 `factorRows` 重命名为 `stepOneRows`,新增 `STEP_TWO_FACTOR_ROWS``stepTwoRows`,并为两步分别提供 `create...Rows`、`build...Outcome`、`build...ValidationPayload`。模板保留第 1 步 `v-if="currentStep === 0"`,新增 `v-else-if="currentStep === 1"` 的原型表格和“保存并进入第 3 步”按钮,最后 `v-else` 才保留第 3 步待配置占位。
`loadSavedAnswer()` 分别读取 `answer.step1Answer``answer.step2Answer` 并按维度恢复;本地草稿保存两个步骤的内容和当前步骤,读取时兼容旧第 1 步草稿。第 2 步保存函数先校验,再调用 `saveStudentTrainingAnswer(TASK_KEY, buildStepTwoAnswerPayload("SAVE", 3))`,步骤配置不少于三步时设置 `currentStep.value = 2`,否则显示已保存但未配置第 3 步。重置时清空两步行数据、将当前步骤设为 0并保持既有 `RESET` 行为。
- [ ] **Step 5: 运行前端契约测试**
Run: `node tests/product-development-factors-step-one.static.test.cjs`
Expected: PASS两个步骤结构、校验和无 `SUBMIT` 约束均满足。
- [ ] **Step 6: 构建生产前端**
Run: `npm run build:prod`
Expected: PASSVite 生产构建成功。
- [ ] **Step 7: 提交前端变更**
```powershell
git add src/api/studentTrainingAnswer.js src/views/foundation/product-development-factors.vue tests/product-development-factors-step-one.static.test.cjs
git commit -m "feat: add product factors step two analysis"
```
## Task 4: 合并前回归验证
**Files:**
- Modify: 无;仅验证 Task 13 的提交。
**Interfaces:**
- Consumes: 前端第 1、2 步保存/恢复逻辑和后端两条校验路由。
- Produces: 可供用户选择集成方式的干净分支,不启动本地后端、不推送或发布。
- [ ] **Step 1: 运行后端完整测试**
Run: `mvn -B test`
Expected: PASS所有 Maven 测试通过。
- [ ] **Step 2: 运行前端契约与生产构建**
Run: `node tests/product-development-factors-step-one.static.test.cjs; npm run build:prod`
Expected: PASS契约和构建均通过。
- [ ] **Step 3: 检查分支状态**
Run: `git status --short`(两个 worktree 分别执行)
Expected: 没有本功能未提交改动;不处理主工作区无关未跟踪文件。
- [ ] **Step 4: 报告提交与验证结果**
报告前后端提交号、测试结果和“保存并进入第 3 步”的用户可见行为。除非用户明确要求,不合并、推送、发布或启动本地后端。
## 自检结果
- 规格覆盖Task 12 负责后端身份和四项文本校验Task 3 负责两步页面分离、恢复、Demo/真实 API 与保存推进Task 4 负责完整回归。
- 占位扫描:计划没有未定义的后续实现项;第 3 步仅为现有导航承接所需的待配置状态,不添加业务。
- 类型一致性:前后端请求字段统一为 `factors[].dimension/successAnalysis/failedAnalysis`;第 2 步路由统一为 `/product-development-factors/step-2/validate`

@ -0,0 +1,51 @@
# 产品开发关键因素模块:第 2 步「技术与合规角度分析」设计
## 目标与范围
仅开发学生端 `product-development-factors` 任务的第 2 步,以及该步骤的服务端校验。第 1 步已完成的用户角度分析保持不变;教师端、管理员端、数据库表、材料下载与第 3 步的具体业务均不在本次范围内。
## 页面与交互
第 2 步标题为“技术与合规角度分析”,正文提示为“技术与合规角度分析,填写下表:”。页面沿用第 1 步的任务导航与深色学生端视觉,但表格内容严格对应原型,固定四列:`维度`、`评估项`、`成功产品`、`失败产品`。不显示产品名称输入框。
固定两行,顺序及评估项如下:
| 维度 | 评估项 |
| --- | --- |
| 技术应用 | 核心技术是否已成熟、可量产?避免使用实验室级原型(如未验证的 AI 模型) |
| 合规与认证 | 是否通过目标市场强制认证目标市场要求的产品资质、检测报告等中国CCC美国FCC欧盟CE出口需符合 RoHS、REACH。是否规避专利与知识产权风险 |
每一行的成功产品、失败产品各提供一个多行分析输入框。四个分析内容去除首尾空白后均非空,点击“保存并进入第 3 步”才会推进。校验成功后使用 `SAVE` 保存,不触发任务总提交或成绩结算。
第 2 步以外不复用该表格:第 1 步保留其已完成页面;第 3 步尚未纳入本次开发时显示轻量待配置占位。若任务配置未包含第 3 步,保存成功并提示未配置下一步,停留在第 2 步。
## 数据与接口
第 2 步答案写入现有 `StudentTrainingAnswer.step2Answer`,不新增数据库表。保存 JSON 只包含两行分析:
```json
{
"factors": [
{
"dimension": "技术应用",
"criteria": "核心技术是否已成熟、可量产?避免使用实验室级原型(如未验证的 AI 模型)",
"successAnalysis": "...",
"failedAnalysis": "..."
}
]
}
```
新增校验接口:`POST /api/student-training-answers/product-development-factors/step-2/validate`。
请求字段为 `factors`,每行拥有 `dimension`、`successAnalysis`、`failedAnalysis`。服务端仅允许已登录学生调用,且必须恰好提交一次“技术应用”和“合规与认证”,每行两列分析均为非空。成功返回 `valid: true``message: "校验通过"`;不符合规则则返回可展示的错误消息。
前端恢复时按维度将 `step1Answer` 恢复到第 1 步、`step2Answer` 恢复到第 2 步;保存第 2 步后将 `currentStep` 写为 `3`,页面切换到 0 基索引 `2`
## 测试与验收
- 页面第 2 步仅展示两行原型维度和四列表头,不显示产品名称字段。
- 四项分析填写完整时,先调用第 2 步校验接口,再以 `saveAction: "SAVE"` 保存 `step2Answer`,最后进入第 3 步;不出现 `SUBMIT`
- 空白分析、缺失/重复/伪造维度与非学生身份均被后端拒绝。
- 已保存的两步内容可以分别恢复,且刷新后恢复进度到第 3 步(若该步骤已配置)。
- 后端服务和控制器测试、前端静态契约测试与生产构建均通过。

@ -237,3 +237,17 @@ export function checkProductDevelopmentFactorsStepOne(payload) {
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }
const productDevelopmentFactorsStepTwoDimensions = ["技术应用", "合规与认证"];
function validateProductDevelopmentFactorsStepTwoDemo(payload) {
const factors = Array.isArray(payload?.factors) ? payload.factors : [];
const dimensions = factors.map((factor) => String(factor?.dimension || "").trim());
if (factors.length !== 2 || new Set(dimensions).size !== 2 || productDevelopmentFactorsStepTwoDimensions.some((dimension) => !dimensions.includes(dimension))) throw new Error("请完成技术应用和合规与认证分析");
const incomplete = factors.find((factor) => !String(factor?.successAnalysis || "").trim() || !String(factor?.failedAnalysis || "").trim());
if (incomplete) throw new Error(`请完成${incomplete.dimension || "该项"}的成功产品和失败产品分析`);
return { valid: true, message: "校验通过" };
}
export function checkProductDevelopmentFactorsStepTwo(payload) {
if (isStudentDemo()) { try { return Promise.resolve({ code: 200, data: validateProductDevelopmentFactorsStepTwoDemo(payload) }); } catch (error) { return Promise.reject(error); } }
return request({ url: "/api/student-training-answers/product-development-factors/step-2/validate", method: "post", data: payload, headers: { repeatSubmit: false } });
}

@ -63,7 +63,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in factorRows" :key="row.dimension"> <tr v-for="row in stepOneRows" :key="row.dimension">
<td class="dimension-cell">{{ row.dimension }}</td> <td class="dimension-cell">{{ row.dimension }}</td>
<td class="criteria-cell">{{ row.criteria }}</td> <td class="criteria-cell">{{ row.criteria }}</td>
<td> <td>
@ -96,9 +96,17 @@
</div> </div>
</template> </template>
<template v-else-if="currentStep === 1">
<p class="factor-intro">技术与合规角度分析填写下表</p>
<div class="training-table-wrap"><table class="factor-table"><thead><tr><th class="dimension-col">维度</th><th class="criteria-col">评估项</th><th>成功产品</th><th>失败产品</th></tr></thead><tbody>
<tr v-for="row in stepTwoRows" :key="row.dimension"><td class="dimension-cell">{{ row.dimension }}</td><td class="criteria-cell">{{ row.criteria }}</td><td><textarea v-model="row.successAnalysis" class="table-textarea" placeholder="填写成功产品在该维度的分析" /></td><td><textarea v-model="row.failedAnalysis" class="table-textarea" placeholder="填写失败产品在该维度的分析" /></td></tr>
</tbody></table></div>
<div class="task-actions"><button type="button" class="btn-nav btn-submit" :disabled="saving" @click="saveAndGoToStepThree"> 3 </button><button type="button" class="btn-nav" :disabled="saving" @click="resetTraining"><el-icon><RefreshLeft /></el-icon></button></div>
</template>
<div v-else class="step-placeholder"> <div v-else class="step-placeholder">
<p> {{ currentStep + 1 }} 步内容待配置</p> <p> {{ currentStep + 1 }} 步内容待配置</p>
<span> 1 步分析已保存可从上方步骤栏返回查看</span> <span>已保存的分析可从上方步骤栏返回查看</span>
</div> </div>
</section> </section>
</section> </section>
@ -117,7 +125,7 @@
<script setup> <script setup>
import { Download, Operation, RefreshLeft } from "@element-plus/icons-vue"; import { Download, Operation, RefreshLeft } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkProductDevelopmentFactorsStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { checkProductDevelopmentFactorsStepOne, checkProductDevelopmentFactorsStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
@ -170,14 +178,19 @@ const STEP_ONE_FACTOR_ROWS = [
"是否能接入主流平台是否支持iOS/Android、HomeKit、米家、Alexa等生态是否支持第三方开发者扩展提供API或SDK鼓励生态共建", "是否能接入主流平台是否支持iOS/Android、HomeKit、米家、Alexa等生态是否支持第三方开发者扩展提供API或SDK鼓励生态共建",
}, },
]; ];
const STEP_TWO_FACTOR_ROWS = [
{ dimension: "技术应用", criteria: "核心技术是否已成熟、可量产?避免使用实验室级原型(如未验证的 AI 模型)" },
{ dimension: "合规与认证", criteria: "是否通过目标市场强制认证目标市场要求的产品资质、检测报告等中国CCC美国FCC欧盟CE出口需符合 RoHS、REACH。是否规避专利与知识产权风险" },
];
const createFactorRows = () => STEP_ONE_FACTOR_ROWS.map((row) => ({ const createStepOneRows = () => STEP_ONE_FACTOR_ROWS.map((row) => ({
...row, ...row,
successAnalysis: "", successAnalysis: "",
failedAnalysis: "", failedAnalysis: "",
})); }));
const createStepTwoRows = () => STEP_TWO_FACTOR_ROWS.map((row) => ({ ...row, successAnalysis: "", failedAnalysis: "" }));
const factorRows = ref(createFactorRows()); const stepOneRows = ref(createStepOneRows());
const stepTwoRows = ref(createStepTwoRows());
const progressItems = [ const progressItems = [
{ text: "进行中:成功/失败产品对比", status: "doing" }, { text: "进行中:成功/失败产品对比", status: "doing" },
@ -193,7 +206,7 @@ onMounted(async () => {
}); });
watch( watch(
() => JSON.stringify(buildOutcome()), () => JSON.stringify(buildDraft()),
() => persistDraft() () => persistDraft()
); );
@ -213,9 +226,10 @@ async function loadSavedAnswer() {
const res = await getStudentTrainingAnswer(TASK_KEY); const res = await getStudentTrainingAnswer(TASK_KEY);
const answer = res?.data; const answer = res?.data;
if (answer?.step1Answer) { if (answer?.step1Answer) {
applySavedAnswer(answer.step1Answer); stepOneRows.value = restoreRows(answer.step1Answer, createStepOneRows);
clearDraft(); clearDraft();
} }
if (answer?.step2Answer) stepTwoRows.value = restoreRows(answer.step2Answer, createStepTwoRows);
restoreCurrentStep(answer?.currentStep); restoreCurrentStep(answer?.currentStep);
} catch (error) { } catch (error) {
// Keep local draft when the backend answer cannot be loaded. // Keep local draft when the backend answer cannot be loaded.
@ -250,10 +264,10 @@ function parseSavedAnswer(value) {
} }
} }
function applySavedAnswer(value) { function restoreRows(value, createRows) {
const parsed = parseSavedAnswer(value); const parsed = parseSavedAnswer(value);
if (!parsed || typeof parsed !== "object") { if (!parsed || typeof parsed !== "object") {
return; return createRows();
} }
if (Array.isArray(parsed.factors)) { if (Array.isArray(parsed.factors)) {
const savedByDimension = parsed.factors.reduce((map, row) => { const savedByDimension = parsed.factors.reduce((map, row) => {
@ -262,12 +276,13 @@ function applySavedAnswer(value) {
} }
return map; return map;
}, {}); }, {});
factorRows.value = createFactorRows().map((row, index) => ({ return createRows().map((row, index) => ({
...row, ...row,
successAnalysis: savedByDimension[row.dimension]?.successAnalysis || parsed.factors[index]?.successAnalysis || "", successAnalysis: savedByDimension[row.dimension]?.successAnalysis || parsed.factors[index]?.successAnalysis || "",
failedAnalysis: savedByDimension[row.dimension]?.failedAnalysis || parsed.factors[index]?.failedAnalysis || "", failedAnalysis: savedByDimension[row.dimension]?.failedAnalysis || parsed.factors[index]?.failedAnalysis || "",
})); }));
} }
return createRows();
} }
function restoreCurrentStep(savedStep) { function restoreCurrentStep(savedStep) {
@ -281,7 +296,7 @@ function restoreCurrentStep(savedStep) {
function persistDraft() { function persistDraft() {
if (!draftReady.value) return; if (!draftReady.value) return;
try { try {
localStorage.setItem(getLocalDraftKey(), JSON.stringify(buildOutcome())); localStorage.setItem(getLocalDraftKey(), JSON.stringify(buildDraft()));
} catch (error) { } catch (error) {
// Local draft is only a convenience; backend save remains the source of truth. // Local draft is only a convenience; backend save remains the source of truth.
} }
@ -291,7 +306,10 @@ function loadDraft() {
try { try {
const raw = localStorage.getItem(getLocalDraftKey()); const raw = localStorage.getItem(getLocalDraftKey());
if (raw) { if (raw) {
applySavedAnswer(raw); const draft = parseSavedAnswer(raw);
if (draft?.stepOne) stepOneRows.value = restoreRows(draft.stepOne, createStepOneRows);
else stepOneRows.value = restoreRows(raw, createStepOneRows);
if (draft?.stepTwo) stepTwoRows.value = restoreRows(draft.stepTwo, createStepTwoRows);
} }
} catch (error) { } catch (error) {
clearDraft(); clearDraft();
@ -306,10 +324,10 @@ function clearDraft() {
} }
} }
function buildOutcome() { function buildStepOneOutcome() {
return { return {
title: taskTitle.value, title: taskTitle.value,
factors: factorRows.value.map((row) => ({ factors: stepOneRows.value.map((row) => ({
dimension: row.dimension, dimension: row.dimension,
criteria: row.criteria, criteria: row.criteria,
successAnalysis: row.successAnalysis, successAnalysis: row.successAnalysis,
@ -317,10 +335,12 @@ function buildOutcome() {
})), })),
}; };
} }
function buildStepTwoOutcome() { return { title: taskTitle.value, factors: stepTwoRows.value.map((row) => ({ dimension: row.dimension, criteria: row.criteria, successAnalysis: row.successAnalysis, failedAnalysis: row.failedAnalysis })) }; }
function buildDraft() { return { stepOne: buildStepOneOutcome(), stepTwo: buildStepTwoOutcome(), currentStep: currentStep.value + 1 }; }
function buildStepOneValidationPayload() { function buildStepOneValidationPayload() {
return { return {
factors: factorRows.value.map(({ dimension, successAnalysis, failedAnalysis }) => ({ factors: stepOneRows.value.map(({ dimension, successAnalysis, failedAnalysis }) => ({
dimension, dimension,
successAnalysis, successAnalysis,
failedAnalysis, failedAnalysis,
@ -328,21 +348,23 @@ function buildStepOneValidationPayload() {
}; };
} }
function buildAnswerPayload(saveAction = "SAVE", persistedStep = currentStep.value + 1) { function buildStepOneAnswerPayload(saveAction = "SAVE", persistedStep = currentStep.value + 1) {
return { return {
step1Answer: JSON.stringify(buildOutcome()), step1Answer: JSON.stringify(buildStepOneOutcome()),
currentStep: persistedStep, currentStep: persistedStep,
submitted: false, submitted: false,
saveAction, saveAction,
}; };
} }
function buildStepTwoValidationPayload() { return { factors: stepTwoRows.value.map(({ dimension, successAnalysis, failedAnalysis }) => ({ dimension, successAnalysis, failedAnalysis })) }; }
function buildStepTwoAnswerPayload(saveAction = "SAVE", persistedStep = currentStep.value + 1) { return { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: persistedStep, submitted: false, saveAction }; }
async function saveAndGoToStepTwo() { async function saveAndGoToStepTwo() {
if (saving.value) return; if (saving.value) return;
saving.value = true; saving.value = true;
try { try {
await checkProductDevelopmentFactorsStepOne(buildStepOneValidationPayload()); await checkProductDevelopmentFactorsStepOne(buildStepOneValidationPayload());
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload("SAVE", 2)); await saveStudentTrainingAnswer(TASK_KEY, buildStepOneAnswerPayload("SAVE", 2));
clearDraft(); clearDraft();
if (trainingSteps.value.length < 2) { if (trainingSteps.value.length < 2) {
proxy?.$modal?.msgSuccess("已保存,当前任务尚未配置第 2 步"); proxy?.$modal?.msgSuccess("已保存,当前任务尚未配置第 2 步");
@ -357,13 +379,27 @@ async function saveAndGoToStepTwo() {
} }
} }
async function saveAndGoToStepThree() {
if (saving.value) return;
saving.value = true;
try {
await checkProductDevelopmentFactorsStepTwo(buildStepTwoValidationPayload());
await saveStudentTrainingAnswer(TASK_KEY, buildStepTwoAnswerPayload("SAVE", 3));
clearDraft();
if (trainingSteps.value.length < 3) { proxy?.$modal?.msgSuccess("已保存,当前任务尚未配置第 3 步"); return; }
currentStep.value = 2;
proxy?.$modal?.msgSuccess("已保存,已进入第 3 步");
} catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成技术应用和合规与认证分析"); } finally { saving.value = false; }
}
async function resetTraining() { async function resetTraining() {
factorRows.value = createFactorRows(); stepOneRows.value = createStepOneRows();
stepTwoRows.value = createStepTwoRows();
currentStep.value = 0; currentStep.value = 0;
clearDraft(); clearDraft();
try { try {
saving.value = true; saving.value = true;
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload("RESET", 1)); await saveStudentTrainingAnswer(TASK_KEY, buildStepOneAnswerPayload("RESET", 1));
} catch (error) { } catch (error) {
// Clearing the page should still work locally even when backend reset fails. // Clearing the page should still work locally even when backend reset fails.
} finally { } finally {
@ -373,7 +409,7 @@ async function resetTraining() {
} }
function exportOutcome() { function exportOutcome() {
const outcome = buildOutcome(); const outcome = buildDraft();
const blob = new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" }); const blob = new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");

@ -13,11 +13,21 @@ assert.match(page, /生态与兼容/, "step one must include ecosystem compatibi
assert.match(page, /v-if="currentStep === 0"/, "the factor table must only render on step one"); assert.match(page, /v-if="currentStep === 0"/, "the factor table must only render on step one");
assert.match(page, /saveAndGoToStepTwo/, "step one must expose a save-and-advance action"); assert.match(page, /saveAndGoToStepTwo/, "step one must expose a save-and-advance action");
assert.match(page, /await checkProductDevelopmentFactorsStepOne\(buildStepOneValidationPayload\(\)\)/, "advance must validate before saving"); assert.match(page, /await checkProductDevelopmentFactorsStepOne\(buildStepOneValidationPayload\(\)\)/, "advance must validate before saving");
assert.match(page, /buildAnswerPayload\("SAVE", 2\)/, "advance must save step two progress without submitting"); assert.match(page, /buildStepOneAnswerPayload\("SAVE", 2\)/, "advance must save step two progress without submitting");
assert.doesNotMatch(page, /v-model="form\.successProduct"/, "the prototype must not display a success-product name input"); assert.doesNotMatch(page, /v-model="form\.successProduct"/, "the prototype must not display a success-product name input");
assert.doesNotMatch(page, /v-model="form\.failedProduct"/, "the prototype must not display a failed-product name input"); assert.doesNotMatch(page, /v-model="form\.failedProduct"/, "the prototype must not display a failed-product name input");
assert.doesNotMatch(page, /buildAnswerPayload\("SUBMIT"\)/, "step one must not submit the entire task"); assert.doesNotMatch(page, /buildStepOneAnswerPayload\("SUBMIT"\)/, "step one must not submit the entire task");
assert.match(api, /export function checkProductDevelopmentFactorsStepOne\(/, "the student API must expose the step-one validator"); assert.match(api, /export function checkProductDevelopmentFactorsStepOne\(/, "the student API must expose the step-one validator");
assert.match(api, /product-development-factors\/step-1\/validate/, "the student API must call the step-one validation route"); assert.match(api, /product-development-factors\/step-1\/validate/, "the student API must call the step-one validation route");
assert.match(page, /const STEP_TWO_FACTOR_ROWS = \[/, "step two must define its fixed rows");
assert.match(page, /技术应用/, "step two must include technology application");
assert.match(page, /合规与认证/, "step two must include compliance and certification");
assert.match(page, /v-else-if="currentStep === 1"/, "the second table must render only on step two");
assert.match(page, /await checkProductDevelopmentFactorsStepTwo\(buildStepTwoValidationPayload\(\)\)/, "step two must validate before saving");
assert.match(page, /step2Answer: JSON\.stringify\(buildStepTwoOutcome\(\)\)/, "step two must save its own answer field");
assert.match(page, /buildStepTwoAnswerPayload\("SAVE", 3\)/, "step two must save progress for step three");
assert.doesNotMatch(page, /buildStepTwoAnswerPayload\("SUBMIT"/, "step two must not submit the task");
assert.match(api, /export function checkProductDevelopmentFactorsStepTwo\(/, "the student API must expose the step-two validator");
assert.match(api, /product-development-factors\/step-2\/validate/, "the student API must call the step-two validation route");
console.log("product development factors step-one contract passed"); console.log("product development factors step-one contract passed");

Loading…
Cancel
Save