merge: new product survey steps one and two

dev-QQq
chenyuan 3 weeks ago
commit 714adac880

@ -0,0 +1,214 @@
# 新产品调查与分析第二步 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:** 让学生完成新产品调查与分析的第二步,并在提交前由后端验证平台、场景、关键词、产品名和 15 张产品图片。
**Architecture:** 将 `new-product-survey.vue` 中旧的三行平台表格替换为单个调查表单,并将其 JSON 继续保存在现有 `StudentTrainingAnswer.step2Answer`。图片先沿用 `/common/upload` 上传,答案中只保存 `{ name, url }`;新增一个无状态的第二步校验服务与控制器入口,前端通过校验后才推进到第三步。
**Tech Stack:** Vue 3 Composition API、Element Plus、Vite、现有 RuoYi request 封装、Spring Boot、JUnit 5、Mockito。
## Global Constraints
- 仅调整学生端 `new-product-survey` 与对应学生答案接口;不改教师端、管理端或其余步骤。
- 案例资料按钮继续读取当前任务的 `materialUrl` 并下载,显示固定文案“下载案例资料”。
- `step2Answer` 必须兼容既有答案存储与本地草稿,不新建图片表。
- 第二步提交必须要求 1 至 5 张图片;选择“其他场景”时必须填写自定义场景。
- 演示模式只做本地校验与会话保存,不调用上传或新增后端校验接口。
---
### Task 1: 后端第二步提交校验
**Files:**
- Create: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/main/java/com/sztzjy/linkCommerce/entity/dto/NewProductSurveyStepTwoValidationRequest.java`
- Create: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/main/java/com/sztzjy/linkCommerce/entity/dto/NewProductSurveyStepTwoValidationResult.java`
- Create: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/main/java/com/sztzjy/linkCommerce/service/NewProductSurveyStepTwoService.java`
- Create: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/main/java/com/sztzjy/linkCommerce/service/impl/NewProductSurveyStepTwoServiceImpl.java`
- Modify: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerController.java`
- Test: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/test/java/com/sztzjy/linkCommerce/service/impl/NewProductSurveyStepTwoServiceImplTest.java`
- Test: `E:/workspace/dianshang/link_commerce/.worktrees/new-product-survey-step1/src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentTrainingAnswerControllerTest.java`
**Interfaces:**
- Consumes: `JwtUser` and `POST /api/student-training-answers/new-product-survey/step-2/validate` request JSON.
- Produces: `NewProductSurveyStepTwoService.validate(NewProductSurveyStepTwoValidationRequest, JwtUser)` returning `{ valid: true, message: "校验通过" }` or a 4xx `ServiceException`.
- [ ] **Step 1: Write failing service tests**
```java
@Test
void validatesACompleteStudentSubmission() {
NewProductSurveyStepTwoValidationResult result = service.validate(request("京东", "AI儿童玩具", "", twoImages()), student());
assertTrue(result.isValid());
}
@Test
void rejectsOtherSceneWithoutCustomSceneAndMoreThanFiveImages() {
assertThrows(ServiceException.class, () -> service.validate(request("京东", "其他场景", "", oneImage()), student()));
assertThrows(ServiceException.class, () -> service.validate(request("京东", "AI儿童玩具", "", sixImages()), student()));
}
```
- [ ] **Step 2: Run the service test to verify it fails**
Run: `mvn -B '-Dtest=NewProductSurveyStepTwoServiceImplTest' test`
Expected: FAIL because the step-two DTO and service do not exist.
- [ ] **Step 3: Implement the DTO, service, and validation rules**
```java
public interface NewProductSurveyStepTwoService {
NewProductSurveyStepTwoValidationResult validate(NewProductSurveyStepTwoValidationRequest request, JwtUser user);
}
// validate roleId == 4; platform in 京东/天猫/抖音电商; scene is nonblank;
// customScene required only for 其他场景; keyword/productName nonblank;
// imageUrls contains 1..5 nonblank URLs. Throw ServiceException(BAD_REQUEST, message) on failure.
```
- [ ] **Step 4: Expose the controller endpoint and its test**
```java
@PostMapping("/new-product-survey/step-2/validate")
public ResultEntity<NewProductSurveyStepTwoValidationResult> validateNewProductSurveyStepTwo(
@RequestBody NewProductSurveyStepTwoValidationRequest answer, HttpServletRequest request) {
return new ResultEntity<>(HttpStatus.OK, "校验通过", newProductSurveyStepTwoService.validate(answer, TokenProvider.getJWTUser(request)));
}
```
Mock the new service in `StudentTrainingAnswerControllerTest` and assert the controller passes the request and JWT user through, returning HTTP 200.
- [ ] **Step 5: Run focused backend tests**
Run: `mvn -B '-Dtest=NewProductSurveyStepTwoServiceImplTest,StudentTrainingAnswerControllerTest' test`
Expected: PASS with zero failures.
- [ ] **Step 6: Commit the backend implementation**
```bash
git add src/main/java src/test/java
git commit -m "feat: validate new product survey step two"
```
### Task 2: 前端第二步表单、上传与答案回填
**Files:**
- Modify: `E:/workspace/dianshang/e-commerce-internet/.worktrees/new-product-survey-step1/src/api/studentTrainingAnswer.js`
- Modify: `E:/workspace/dianshang/e-commerce-internet/.worktrees/new-product-survey-step1/src/views/foundation/new-product-survey.vue`
- Modify: `E:/workspace/dianshang/e-commerce-internet/.worktrees/new-product-survey-step1/tests/new-product-survey-step-one.static.test.cjs`
**Interfaces:**
- Consumes: `uploadStudentTrainingFile(FormData)`, `checkNewProductSurveyStepTwo(form)` and the backend validation response.
- Produces: `step2Answer` JSON `{ platform, scene, customScene, keyword, productName, images: Array<{name, url}> }` and only advances the wizard after validation succeeds.
- [ ] **Step 1: Write failing frontend static assertions**
```js
assert.match(source, /AI智能家居[\s\S]*AI美妆辅助[\s\S]*其他场景/);
assert.match(source, /v-if="step2Form\.scene === '其他场景'"/);
assert.match(source, /MAX_STEP_TWO_IMAGES = 5/);
assert.match(source, /checkNewProductSurveyStepTwo/);
assert.match(source, /step2Answer: JSON\.stringify\(step2Form\.value\)/);
```
- [ ] **Step 2: Run the static test to verify it fails**
Run: `node tests/new-product-survey-step-one.static.test.cjs`
Expected: FAIL because the page still renders `step2Rows` and has no step-two validation client.
- [ ] **Step 3: Add the API client and local demo validator**
```js
export function checkNewProductSurveyStepTwo(form) {
if (isStudentDemo()) return Promise.resolve({ code: 200, data: validateStepTwoDemo(form) });
return request({
url: "/api/student-training-answers/new-product-survey/step-2/validate",
method: "post",
data: { ...form, imageUrls: form.images.map((image) => image.url) },
headers: { repeatSubmit: false },
});
}
```
`validateStepTwoDemo` must apply the same required-field, other-scene, and 15 image count rules and reject invalid data with an `Error`.
- [ ] **Step 4: Replace the old table with the second-step form**
```vue
<el-radio-group v-model="step2Form.platform">
<el-radio v-for="option in PLATFORM_OPTIONS" :key="option.value" :label="option.value">{{ option.label }}</el-radio>
</el-radio-group>
<input v-if="step2Form.scene === '其他场景'" v-model.trim="step2Form.customScene" />
<input type="file" accept="image/*" multiple @change="uploadStepTwoImages" />
```
Use constants for the three platform options and six scene options. Implement `uploadStepTwoImages`, `removeStepTwoImage`, and `previewStepTwoImage`: reject non-images and selections that would exceed five files; upload files with `FormData`; store only name and resolved URL; show thumbnails in a dialog-capable preview. Replace all `step2Rows` watch, reset, draft, save, outcome, and restoration paths with `step2Form`, while retaining safe fallback behavior for old row-array answers.
- [ ] **Step 5: Gate next-step navigation on step-two validation**
```js
if (currentStep.value === 2) {
await checkNewProductSurveyStepTwo(step2Form.value);
await saveAnswer("IN_PROGRESS", true);
advanceStep();
return;
}
```
Show a human-readable validation/upload error and leave the user on step two on failure. Keep step-one grading and steps three/four navigation behavior unchanged.
- [ ] **Step 6: Run frontend static test and production build**
Run: `node tests/new-product-survey-step-one.static.test.cjs`
Expected: PASS.
Run: `node 'E:\workspace\dianshang\e-commerce-internet\node_modules\vite\bin\vite.js' build --config '.\vite.config.js'`
Expected: build exits with code 0; retain only the repositorys pre-existing warnings.
- [ ] **Step 7: Commit the frontend implementation**
```bash
git add src/api/studentTrainingAnswer.js src/views/foundation/new-product-survey.vue tests/new-product-survey-step-one.static.test.cjs
git commit -m "feat: collect new product survey step two"
```
### Task 3: 全量验证与运行中的服务检查
**Files:**
- Modify: no source files expected.
**Interfaces:**
- Consumes: committed frontend and backend worktree changes.
- Produces: evidence that the new APIs compile and the updated student page is available on the already-started local services.
- [ ] **Step 1: Run the complete backend suite**
Run: `mvn -B test`
Expected: all tests pass with zero failures.
- [ ] **Step 2: Run all standalone frontend tests**
Run:
```powershell
$tests = Get-ChildItem tests -File | Where-Object { $_.Name -like '*.test.cjs' -or $_.Name -like '*.test.mjs' }
foreach ($test in $tests) { node $test.FullName }
```
Expected: every test file exits 0.
- [ ] **Step 3: Verify the running services**
Run: `Invoke-WebRequest http://127.0.0.1:147/index.html -UseBasicParsing` and `Invoke-WebRequest http://127.0.0.1:7548/doc.html -UseBasicParsing`.
Expected: both return HTTP 200. If the backend process predates the new commit, rebuild the backend JAR with `mvn -B package -DskipTests` and restart only the project JAR on port 7548.
- [ ] **Step 4: Report verification and commits**
Provide test/build outcomes, local URLs, and the two feature commit hashes. Do not merge the isolated worktree branches without an explicit user instruction.

@ -0,0 +1,44 @@
# 新产品调查与分析:第二步选定调查平台与产品
## 范围
仅调整学生端“新产品调查与分析”(`new-product-survey`)的第二步,以及支持该步骤提交校验的后端接口。保留当前任务配置的案例资料下载入口和下载行为;不调整教师端、管理端及其余实训步骤。
## 页面与交互
第二步标题为“选定调查平台与产品”。学生以消费者身份,通过搜索、浏览和对比采集一个真实 AI 产品的数据,并完成以下字段:
1. 调查平台:必选单选项为京东、天猫、抖音电商,并展示原型中的平台说明。
2. 调查场景:必选单选项为 AI 智能家居、AI 智能办公、AI 儿童玩具、AI 学习设备、AI 美妆辅助、其他场景;每项展示示例。选择“其他场景”后显示必填文本框,填写自定义场景名称。
3. 搜索关键词:必填单行输入框,提示学生按“核心词 + 属性词 + 场景词”组织关键词,并展示原型示例。
4. 产品名称:必填单行输入框。
5. 产品图片:必传 1 至 5 张图片。上传完成后以缩略图显示;点击缩略图查看大图,学生可删除已上传的图片。超过 5 张、非图片文件或上传失败均显示明确提示,且不写入答案。
步骤导航继续使用既有四步名称。点击“下一步”时,先调用第二步校验接口;校验通过后保存答案并进入第三步,未通过时停留当前页并显示校验提示。普通“保存”仍可保存草稿,不强制所有字段完整。
## 数据持久化与接口
页面将一个对象序列化到既有学生答案的 `step2Answer` 字段:
```json
{
"platform": "京东",
"scene": "AI儿童玩具",
"customScene": "",
"keyword": "AI智能对话机器人 3-6岁 玩具",
"productName": "示例产品",
"images": [
{ "name": "product-main.png", "url": "/profile/upload/202608/...png" }
]
}
```
图片继续上传到现有 `/common/upload`,只在答案 JSON 中保存原始文件名和服务端返回的 URL不新增图片表。刷新或再次进入页面时回填表单和缩略图旧版第二步表格 JSON 可以安全忽略,不阻止学生填写新版内容。
后端新增仅学生可调用的 `POST /api/student-training-answers/new-product-survey/step-2/validate`。请求包括平台、场景、自定义场景、关键词、产品名称和图片 URL 列表。接口校验:学生身份、平台与场景已选择、选择“其他场景”时自定义场景非空、关键词与产品名称非空、图片 URL 数量在 1 至 5 范围且每项非空。校验成功后返回成功结果;前端随后复用现有学生实训答案保存接口持久化 `step2Answer`。不新增标准答案或判题逻辑,因为本步骤采集的是学生自行选择的真实产品资料。
## 兼容性与验证
现有 `student_training_answer` 与步骤答案明细表继续承载 JSON因此成绩、进度和导出链路保持兼容。演示模式沿用浏览器会话存储并在本地执行同等的必填与图片数量校验不发起上传或后端校验请求。
验证包括:后端服务与控制器的合法请求、缺失字段、其他场景未填写、图片数量越界和非学生权限测试;前端静态测试覆盖选择项、自定义场景条件渲染、上传数量限制、缩略图预览、数据保存与校验调用;再执行前端构建与后端测试。

@ -1,6 +1,20 @@
import request from "@/utils/request";
import { getDemoAnswerKey, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const newProductSurveyStepOneAnswers = {
"selling-point-1": "核心层",
"selling-point-2": "有形层",
"selling-point-3": "核心层",
"selling-point-4": "核心层",
"selling-point-5": "有形层",
"selling-point-6": "有形层",
"selling-point-7": "有形层",
"selling-point-8": "延伸层",
"selling-point-9": "延伸层",
"selling-point-10": "延伸层",
"selling-point-11": "有形层",
};
function demoResult(taskKey) {
try { return JSON.parse(sessionStorage.getItem(getDemoAnswerKey(taskKey)) || "null"); } catch (error) { return null; }
}
@ -49,3 +63,74 @@ export function uploadStudentTrainingFile(data) {
},
});
}
export function checkNewProductSurveyStepOne(items) {
if (isStudentDemo()) {
const results = Array.isArray(items)
? items.map((item) => {
const correct = newProductSurveyStepOneAnswers[item?.id] === item?.category;
return {
id: item?.id,
correct,
message: correct ? "分类正确" : "分类有误,请重新选择",
};
})
: [];
const correctCount = results.filter((item) => item.correct).length;
return Promise.resolve({
code: 200,
data: {
items: results,
correctCount,
totalCount: results.length,
allCorrect: results.length === Object.keys(newProductSurveyStepOneAnswers).length && correctCount === results.length,
},
});
}
return request({
url: "/api/student-training-answers/new-product-survey/step-1/check",
method: "post",
data: { items },
headers: { repeatSubmit: false },
});
}
function validateNewProductSurveyStepTwoDemo(form) {
const value = form || {};
const images = Array.isArray(value.images) ? value.images : [];
if (!['京东', '天猫', '抖音电商'].includes(String(value.platform || '').trim())) {
throw new Error('请选择调查平台');
}
if (!String(value.scene || '').trim()) {
throw new Error('请选择调查场景');
}
if (value.scene === '其他场景' && !String(value.customScene || '').trim()) {
throw new Error('请填写自定义调查场景');
}
if (!String(value.keyword || '').trim()) {
throw new Error('请填写搜索关键词');
}
if (!String(value.productName || '').trim()) {
throw new Error('请填写产品名称');
}
if (images.length < 1 || images.length > 5 || images.some((image) => !String(image?.url || '').trim())) {
throw new Error('请上传 1 至 5 张产品图片');
}
return { valid: true, message: '校验通过' };
}
export function checkNewProductSurveyStepTwo(form) {
if (isStudentDemo()) {
try {
return Promise.resolve({ code: 200, data: validateNewProductSurveyStepTwoDemo(form) });
} catch (error) {
return Promise.reject(error);
}
}
return request({
url: '/api/student-training-answers/new-product-survey/step-2/validate',
method: 'post',
data: { ...form, imageUrls: (form?.images || []).map((image) => image?.url) },
headers: { repeatSubmit: false },
});
}

@ -1,7 +1,7 @@
<template>
<el-button class="material-download" :plain="!material?.url" @click="handleDownload">
<el-icon><Download /></el-icon>
{{ material?.name || "案例文档" }}
下载案例资料
</el-button>
</template>

@ -50,60 +50,38 @@
<p class="section-eyebrow">Step 01</p>
<h3>{{ getStepName(1) }}</h3>
</div>
<span class="step-chip">案例信息提取</span>
<span class="step-chip">卖点分类练习</span>
</div>
<p class="step-desc">请根据实训要求中的案例一信息分别从核心层有形层延伸层提取信息并填写对应信息</p>
<p class="step-desc">查看案例产品详情仔细阅读主图及详情页信息对下列产品卖点进行三层次分类</p>
<label class="field-block">
<span>该款智能产品平台类目是</span>
<input v-model="step1Form.category" class="input-field input-single" />
</label>
<label class="field-block">
<span>核心层从上图可以提炼出该款智能产品解决的用户痛点为</span>
<textarea v-model="step1Form.corePain" class="input-field" rows="4" />
</label>
<label class="field-block">
<span>有形层从产品主图可以看出关于产品有形层的主要卖点为</span>
<textarea v-model="step1Form.tangibleSellingPoint" class="input-field" rows="4" />
</label>
<label class="field-block">
<span>价格带为</span>
<div class="price-options">
<button
v-for="option in priceOptions"
:key="option.value"
type="button"
:class="['price-option', { active: step1Form.priceBand === option.value }]"
@click="step1Form.priceBand = option.value"
>
{{ step1Form.priceBand === option.value ? "√" : "" }}{{ option.label }}
</button>
</div>
</label>
<p class="step-desc">延伸层从产品主图可以看出关于产品品牌保证发货等延伸层相关描述为</p>
<div class="form-grid three">
<label class="field-block">
<span>店铺名称</span>
<input v-model="step1Form.storeName" class="input-field input-single" />
</label>
<label class="field-block">
<span>店铺星级</span>
<input v-model="step1Form.storeRating" class="input-field input-single" />
</label>
<label class="field-block">
<span>发货优势</span>
<input v-model="step1Form.shippingAdvantage" class="input-field input-single" />
</label>
<div class="training-table-wrap classification-table-wrap">
<table class="training-data-table classification-table">
<thead>
<tr>
<th>序号</th>
<th>卖点</th>
<th>三层次分类</th>
</tr>
</thead>
<tbody>
<tr v-for="(point, index) in STEP_ONE_SELLING_POINTS" :key="point.id" :class="feedbackClass(point.id)">
<td class="classification-number">{{ index + 1 }}</td>
<td class="classification-selling-point">{{ point.text }}</td>
<td class="classification-select-cell">
<el-select v-model="classifications[point.id]" placeholder="请选择分类" :disabled="checking" @change="clearFeedback(point.id)">
<el-option v-for="category in LAYER_OPTIONS" :key="category" :label="category" :value="category" />
</el-select>
<p v-if="checkResult?.itemsById?.[point.id]" class="classification-feedback">
{{ checkResult.itemsById[point.id].message }}
</p>
</td>
</tr>
</tbody>
</table>
</div>
<label class="field-block">
<span>根据该产品主图详情评价等信息在产品介绍外观设计和功能上您有何改进建议</span>
<textarea v-model="step1Form.improvement" class="input-field" rows="4" />
</label>
<p v-if="checkResult" class="classification-summary" :class="{ 'is-success': checkResult.allCorrect }">
本次判题{{ checkResult.correctCount }} / {{ checkResult.totalCount }} 项分类正确
</p>
</div>
</section>
@ -114,33 +92,57 @@
<p class="section-eyebrow">Step 02</p>
<h3>{{ getStepName(2) }}</h3>
</div>
<span class="step-chip">平台与关键词</span>
<span class="step-chip">实地调研记录</span>
</div>
<p class="step-desc">
优先选择京东天猫抖音等主流电商平台至少选择 3 个高流量使用场景采用核心词 + 属性词 + 场景词的电商搜索热词逻辑记录产品
</p>
<div class="training-table-wrap">
<table class="training-data-table">
<thead>
<tr>
<th>使用场景</th>
<th>搜索平台</th>
<th>搜索关键词电商热词</th>
<th>平台类目</th>
<th>销量参考近30天</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in step2Rows" :key="index">
<td><input v-model="row.scene" class="cell-input" placeholder="如:家庭学习" /></td>
<td><input v-model="row.platform" class="cell-input" placeholder="京东 / 天猫 / 抖音" /></td>
<td><input v-model="row.keyword" class="cell-input" placeholder="AI学习机 小学生 护眼" /></td>
<td><input v-model="row.category" class="cell-input" placeholder="学习机 / 智能硬件" /></td>
<td><input v-model="row.sales" class="cell-input" placeholder="如:月销 5000+" /></td>
</tr>
</tbody>
</table>
<p class="step-desc">请以真实消费者身份进入电商平台通过搜索浏览和对比采集 1 AI 产品的真实数据并完成产品三层次分析</p>
<div class="survey-form">
<section class="survey-field-group">
<p class="survey-field-title"><b>01</b> 选择调研平台</p>
<el-radio-group v-model="step2Form.platform" class="survey-options">
<el-radio v-for="option in STEP_TWO_PLATFORM_OPTIONS" :key="option.value" :label="option.value" class="survey-option">
<strong>{{ option.label }}</strong><span>{{ option.description }}</span>
</el-radio>
</el-radio-group>
</section>
<section class="survey-field-group">
<p class="survey-field-title"><b>02</b> 确定调研场景</p>
<el-radio-group v-model="step2Form.scene" class="survey-options">
<el-radio v-for="option in STEP_TWO_SCENE_OPTIONS" :key="option.value" :label="option.value" class="survey-option">
<strong>{{ option.label }}</strong><span>{{ option.description }}</span>
</el-radio>
</el-radio-group>
<input v-if="step2Form.scene === ''" v-model.trim="step2Form.customScene" class="input-single custom-scene-input" placeholder="请填写其他调研场景" maxlength="60" />
</section>
<section class="survey-field-group">
<p class="survey-field-title"><b>03</b> 设计搜索关键词</p>
<p class="survey-field-hint">遵循核心词 + 属性词 + 场景词例如AI智能音箱 语音控制 家居AI办公鼠标 语音转写 会议</p>
<input v-model.trim="step2Form.keyword" class="input-single" placeholder="请输入搜索关键词" maxlength="120" />
</section>
<section class="survey-field-group">
<p class="survey-field-title"><b>04</b> 记录产品信息并上传图片</p>
<p class="survey-field-hint">请在搜索结果中按销量排序从排名前 10 的产品中选择 1 款产品上传主图及详情页截图最多 5 </p>
<label class="product-name-field">
<span>产品名称</span>
<input v-model.trim="step2Form.productName" class="input-single" placeholder="请输入产品名称" maxlength="120" />
</label>
<label class="image-upload-trigger" :class="{ disabled: uploadingImages || step2Form.images.length >= MAX_STEP_TWO_IMAGES }">
<input type="file" accept="image/*" multiple :disabled="uploadingImages || step2Form.images.length >= MAX_STEP_TWO_IMAGES" @change="uploadStepTwoImages" />
<span>{{ uploadingImages ? '图片上传中…' : `上传产品图片(${step2Form.images.length}/${MAX_STEP_TWO_IMAGES}` }}</span>
</label>
<div v-if="step2Form.images.length" class="product-image-list">
<figure v-for="(image, index) in step2Form.images" :key="`${image.url}-${index}`" class="product-image-card">
<button type="button" class="product-image-preview" @click="previewStepTwoImage(image.url)">
<img :src="image.url" :alt="`${step2Form.productName || '产品'}图片 ${index + 1}`" />
</button>
<figcaption :title="image.name">{{ image.name }}</figcaption>
<button type="button" class="remove-image-button" :aria-label="` ${index + 1}`" @click="removeStepTwoImage(index)"></button>
</figure>
</div>
</section>
</div>
</div>
</section>
@ -216,12 +218,12 @@
<el-icon><ArrowLeft /></el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
<button type="button" class="btn-nav btn-save" :disabled="saving || checking || uploadingImages" @click="saveCurrentProgress">
<el-icon><CircleCheck /></el-icon>
保存当前进度
</button>
<button type="button" class="btn-nav btn-primary" :disabled="currentStep === steps.length" @click="nextStep">
下一步
<button type="button" class="btn-nav btn-primary" :disabled="currentStep === steps.length || saving || checking || uploadingImages" @click="nextStep">
{{ checking ? "正在判题" : "下一步" }}
<el-icon><ArrowRight /></el-icon>
</button>
</div>
@ -239,6 +241,10 @@
</section>
</main>
<el-dialog v-model="imagePreviewOpen" title="产品图片预览" width="min(760px, 92vw)" append-to-body>
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" alt="产品图片大图" class="product-image-full" />
</el-dialog>
<TrainingAiSidebar
:study-tip="currentStudyTip"
reference-title="产品三层次分析参考"
@ -263,7 +269,7 @@ import {
TrendCharts,
} from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { checkNewProductSurveyStepOne, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user";
import { extractTrainingStepNames } from "@/views/training/taskKeyMap";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
@ -276,14 +282,18 @@ const userStore = useUserStore();
const taskConfig = ref(null);
const saving = ref(false);
const checking = ref(false);
const uploadingImages = ref(false);
const imagePreviewOpen = ref(false);
const imagePreviewUrl = ref("");
const draftReady = ref(false);
const currentStep = ref(1);
const defaultSteps = [
{ no: 1, name: "产品三层次理论应用", icon: Search },
{ no: 1, name: "产品卖点的三层次分类", icon: Search },
{ no: 2, name: "选定调查平台与产品", icon: DataAnalysis },
{ no: 3, name: "AI产品三层次分析", icon: TrendCharts },
{ no: 4, name: "AI技术价值分析", icon: Operation },
{ no: 3, name: "AI产品三层次分析", icon: TrendCharts },
{ no: 4, name: "AI应用价值分析", icon: Operation },
];
const defaultGoals = [
@ -313,29 +323,40 @@ const steps = computed(() => {
}));
});
const priceOptions = [
{ label: "1000元以下的基础款", value: "basic" },
{ label: "1000-3000元的进阶全能款", value: "advanced" },
{ label: "3000元以上的旗舰高端款", value: "flagship" },
const LAYER_OPTIONS = ["核心层", "有形层", "延伸层"];
const MAX_STEP_TWO_IMAGES = 5;
const STEP_TWO_PLATFORM_OPTIONS = [
{ value: "京东", label: "京东", description: "3C/智能硬件类目数据全、评价真实" },
{ value: "天猫", label: "天猫", description: "品牌 AI 产品丰富,详情页设计成熟" },
{ value: "抖音电商", label: "抖音电商", description: "爆款 AI 玩具/家居新品多,价格带清晰" },
];
const createStep1Form = () => ({
category: "",
corePain: "",
tangibleSellingPoint: "",
priceBand: "basic",
storeName: "",
storeRating: "",
shippingAdvantage: "",
improvement: "",
});
const STEP_TWO_SCENE_OPTIONS = [
{ value: "AI智能家居", label: "AI智能家居", description: "如 AI 音箱、AI 台灯、AI 门锁" },
{ value: "AI智能办公", label: "AI智能办公", description: "如 AI 鼠标、AI 录音笔、AI 翻译机" },
{ value: "AI儿童玩具", label: "AI儿童玩具", description: "如 AI 早教机器人、AI 对话玩偶" },
{ value: "AI学习设备", label: "AI学习设备", description: "如 AI 学习机、AI 点读笔" },
{ value: "AI美妆辅助", label: "AI美妆辅助", description: "如 AI 皮肤检测仪、AI 试妆镜" },
{ value: "其他场景", label: "其他场景", description: "填写你发现的其他高流量 AI 应用场景" },
];
const createStep2Rows = () => [
{ scene: "", platform: "", keyword: "", category: "", sales: "" },
{ scene: "", platform: "", keyword: "", category: "", sales: "" },
{ scene: "", platform: "", keyword: "", category: "", sales: "" },
const STEP_ONE_SELLING_POINTS = [
{ id: "selling-point-1", text: "生日礼物女孩子毕业儿童3到6岁10小女童" },
{ id: "selling-point-2", text: "AI智能机器人毛绒玩具" },
{ id: "selling-point-3", text: "豆包+DeepSeek六大混合模型多语种AI对话" },
{ id: "selling-point-4", text: "覆盖百科、算术、语文、诗词、AI翻译等早教能力强还具备情绪感知安抚功能" },
{ id: "selling-point-5", text: "安全亲肤,零甲醛零荧光剂,母婴级不掉毛" },
{ id: "selling-point-6", text: "柔软手感舒心无忧内赠USB充电线" },
{ id: "selling-point-7", text: "嘴巴会动,翅膀煽动,脖子扭动,触摸感应触发,说话跳舞同步" },
{ id: "selling-point-8", text: "可自由定制角色/专属音色,打造宝宝专属小伙伴,适配哄睡、早教、陪伴多场景" },
{ id: "selling-point-9", text: "快递免运费预计7小时内发货" },
{ id: "selling-point-10", text: "大促价保、假一赔四、极速退款、7天无理由退换" },
{ id: "selling-point-11", text: "店铺优惠后169元起" },
];
const createStep2Form = () => ({ platform: "", scene: "", customScene: "", keyword: "", productName: "", images: [] });
const createStep3Rows = () => [
{
dimension: "核心层",
@ -369,8 +390,9 @@ const createStep4Form = () => ({
extendedValue: "",
});
const step1Form = ref(createStep1Form());
const step2Rows = ref(createStep2Rows());
const classifications = ref({});
const checkResult = ref(null);
const step2Form = ref(createStep2Form());
const step3Rows = ref(createStep3Rows());
const step4Form = ref(createStep4Form());
@ -383,7 +405,7 @@ const getLocalDraftKey = () => {
const currentStudyTip = computed(() => {
const tips = {
1: "当前步骤建议:先从案例或电商详情页中拆出核心层、有形层、延伸层,不要把功能和用户利益混在一起。",
2: "当前步骤建议:选择主流电商平台,并用“核心词 + 属性词 + 场景词”的组合记录关键词和销量参考。",
2: "当前步骤建议:选择一个主流电商平台与真实 AI 产品,用“核心词 + 属性词 + 场景词”搜索并保留主图、详情页截图。",
3: "当前步骤建议:同一维度横向比较 3 个产品,重点找出 AI 技术在哪一层创造了差异化。",
4: "当前步骤建议:把前面三步的发现收束为产品开发启发,说明哪些价值值得继续强化。",
};
@ -405,7 +427,7 @@ onMounted(async () => {
});
watch(
[step1Form, step2Rows, step3Rows, step4Form, currentStep],
[classifications, checkResult, step2Form, step3Rows, step4Form, currentStep],
() => persistDraft(),
{ deep: true }
);
@ -471,7 +493,7 @@ function applySavedAnswer(value) {
if (parsed.step1Answer || parsed.step2Answer || parsed.step3Answer || parsed.step4Answer) {
let loaded = false;
const step1Parsed = safeParse(parsed.step1Answer);
if (step1Parsed?.step1Form || step1Parsed?.step2Rows || step1Parsed?.step3Rows || step1Parsed?.step4Form) {
if (step1Parsed?.step1Form || step1Parsed?.step2Form || step1Parsed?.step2Rows || step1Parsed?.step3Rows || step1Parsed?.step4Form) {
loaded = applySavedAnswer(step1Parsed) || loaded;
} else {
loaded = applyStep1Answer(step1Parsed || parsed.step1Answer) || loaded;
@ -485,7 +507,7 @@ function applySavedAnswer(value) {
let loaded = false;
loaded = applyStep1Answer(parsed.step1Form || parsed) || loaded;
loaded = applyStep2Answer(parsed.step2Rows || parsed.rows2 || parsed.platformRows) || loaded;
loaded = applyStep2Answer(parsed.step2Form || parsed.step2Answer || parsed.step2Rows || parsed.rows2 || parsed.platformRows) || loaded;
loaded = applyStep3Answer(parsed.step3Rows || parsed.rows3 || parsed.layerRows) || loaded;
loaded = applyStep4Answer(parsed.step4Form || parsed.aiValue || parsed.valueAnalysis) || loaded;
currentStep.value = Math.min(Math.max(Number(parsed.currentStep || 1), 1), 4);
@ -495,19 +517,105 @@ function applySavedAnswer(value) {
function applyStep1Answer(value) {
const parsed = safeParse(value);
if (!parsed || typeof parsed !== "object") return false;
const source = parsed.step1Form || parsed;
const fields = Object.keys(createStep1Form());
const hasValue = fields.some((key) => source[key] !== undefined && source[key] !== null && source[key] !== "");
if (!hasValue) return false;
step1Form.value = { ...createStep1Form(), ...pickFields(source, fields) };
return true;
const source = parsed.classifications || parsed.step1Form || parsed;
const restored = STEP_ONE_SELLING_POINTS.reduce((result, point) => {
if (LAYER_OPTIONS.includes(source?.[point.id])) {
result[point.id] = source[point.id];
}
return result;
}, {});
classifications.value = restored;
checkResult.value = normalizeCheckResult(parsed.checkResult);
return Object.keys(restored).length > 0;
}
function normalizeCheckResult(value) {
if (!value || typeof value !== "object" || !Array.isArray(value.items)) return null;
const itemsById = value.items.reduce((result, item) => {
if (item?.id) result[item.id] = item;
return result;
}, {});
return {
items: value.items,
itemsById,
correctCount: Number(value.correctCount || 0),
totalCount: Number(value.totalCount || value.items.length),
allCorrect: Boolean(value.allCorrect),
};
}
function feedbackClass(id) {
const item = checkResult.value?.itemsById?.[id];
if (!item) return "";
return item.correct ? "classification-row--correct" : "classification-row--incorrect";
}
function clearFeedback(id) {
if (checkResult.value?.itemsById?.[id]) {
checkResult.value = null;
}
}
function hasCompletedClassifications() {
return STEP_ONE_SELLING_POINTS.every((point) => LAYER_OPTIONS.includes(classifications.value[point.id]));
}
function buildCheckItems() {
return STEP_ONE_SELLING_POINTS.map((point) => ({ id: point.id, category: classifications.value[point.id] }));
}
async function uploadStepTwoImages(event) {
const input = event.target;
const files = Array.from(input?.files || []);
input.value = "";
if (!files.length) return;
if (files.some((file) => !file.type?.startsWith("image/"))) {
proxy?.$modal?.msgWarning("只能上传图片文件");
return;
}
if (step2Form.value.images.length + files.length > MAX_STEP_TWO_IMAGES) {
proxy?.$modal?.msgWarning(`产品图片最多上传 ${MAX_STEP_TWO_IMAGES}`);
return;
}
uploadingImages.value = true;
try {
for (const file of files) {
const data = new FormData();
data.append("file", file);
const res = await uploadStudentTrainingFile(data);
const url = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || "";
if (!url) throw new Error("图片上传失败,请重试");
step2Form.value.images.push({ name: res?.originalFilename || file.name, url });
}
proxy?.$modal?.msgSuccess("产品图片上传成功");
} catch (error) {
proxy?.$modal?.msgError(error?.message || "图片上传失败,请稍后重试");
} finally {
uploadingImages.value = false;
}
}
function removeStepTwoImage(index) {
step2Form.value.images.splice(index, 1);
}
function previewStepTwoImage(url) {
imagePreviewUrl.value = url;
imagePreviewOpen.value = true;
}
function applyStep2Answer(value) {
const parsed = safeParse(value);
const rows = Array.isArray(parsed) ? parsed : parsed?.step2Rows || parsed?.rows || parsed?.platformRows;
if (!Array.isArray(rows) || !rows.length) return false;
step2Rows.value = normalizeRows(rows, createStep2Rows());
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") return false;
const source = parsed.step2Form || parsed;
const fields = ["platform", "scene", "customScene", "keyword", "productName"];
const images = Array.isArray(source.images)
? source.images.filter((image) => image?.url).slice(0, MAX_STEP_TWO_IMAGES).map((image) => ({ name: image.name || "产品图片", url: image.url }))
: [];
const hasValue = fields.some((field) => String(source[field] || "").trim()) || images.length;
if (!hasValue) return false;
step2Form.value = { ...createStep2Form(), ...pickFields(source, fields), images };
return true;
}
@ -564,8 +672,8 @@ function buildAnswerPayload(status) {
return {
saveAction,
currentStep: currentStep.value,
step1Answer: JSON.stringify(step1Form.value),
step2Answer: JSON.stringify(step2Rows.value),
step1Answer: JSON.stringify({ classifications: classifications.value, checkResult: checkResult.value }),
step2Answer: JSON.stringify(step2Form.value),
step3Answer: JSON.stringify(step3Rows.value),
step4Answer: JSON.stringify(step4Form.value),
};
@ -591,7 +699,48 @@ function submitTask() {
return saveAnswer("SUBMITTED");
}
function nextStep() {
async function nextStep() {
if (currentStep.value === 2) {
checking.value = true;
try {
await checkNewProductSurveyStepTwo(step2Form.value);
await saveAnswer("IN_PROGRESS", true);
proxy?.$modal?.msgSuccess("第二步信息已校验并保存");
advanceStep();
} catch (error) {
proxy?.$modal?.msgWarning(error?.message || "请完善第二步调研信息后再进入下一步");
} finally {
checking.value = false;
}
return;
}
if (currentStep.value !== 1) {
advanceStep();
return;
}
if (!hasCompletedClassifications()) {
proxy?.$modal?.msgWarning("请完成全部卖点分类后再进入下一步");
return;
}
checking.value = true;
try {
const res = await checkNewProductSurveyStepOne(buildCheckItems());
checkResult.value = normalizeCheckResult(res?.data);
await saveAnswer("IN_PROGRESS", true);
if (checkResult.value?.allCorrect) {
proxy?.$modal?.msgSuccess("分类全部正确,已进入下一步");
advanceStep();
} else {
proxy?.$modal?.msgWarning("存在分类错误,请根据提示修改后重新判题");
}
} catch (error) {
proxy?.$modal?.msgError(error?.message || "判题失败,请稍后重试");
} finally {
checking.value = false;
}
}
function advanceStep() {
if (currentStep.value < steps.value.length) {
currentStep.value += 1;
}
@ -604,8 +753,11 @@ function prevStep() {
}
function resetExample() {
step1Form.value = createStep1Form();
step2Rows.value = createStep2Rows();
classifications.value = {};
checkResult.value = null;
step2Form.value = createStep2Form();
imagePreviewUrl.value = "";
imagePreviewOpen.value = false;
step3Rows.value = createStep3Rows();
step4Form.value = createStep4Form();
currentStep.value = 1;
@ -619,8 +771,9 @@ function buildOutcome() {
taskId: taskConfig.value?.id || "",
currentStep: currentStep.value,
steps: steps.value.map((item) => item.name),
step1Form: step1Form.value,
step2Rows: step2Rows.value,
classifications: classifications.value,
checkResult: checkResult.value,
step2Form: step2Form.value,
step3Rows: step3Rows.value,
step4Form: step4Form.value,
};
@ -1118,6 +1271,202 @@ function exportOutcome() {
}
}
.survey-form {
display: grid;
gap: 18px;
}
.survey-field-group {
padding: 16px;
border: 1px solid rgba(74, 186, 234, 0.24);
border-radius: 18px;
background: linear-gradient(135deg, rgba(8, 51, 77, 0.48), rgba(1, 21, 36, 0.5));
}
.survey-field-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 12px;
color: #f0fbff;
font-size: 16px;
font-weight: 900;
b {
display: inline-grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 50%;
color: #062236;
background: #66dcff;
font-size: 12px;
}
}
.survey-field-hint {
margin: -4px 0 13px 33px;
color: #a9cee2;
font-size: 13px;
line-height: 1.65;
}
.survey-options {
display: grid;
gap: 9px;
}
:deep(.survey-option.el-radio) {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
height: auto;
margin-right: 0;
padding: 10px 12px;
border: 1px solid rgba(67, 140, 181, 0.38);
border-radius: 12px;
color: #c9e8f7;
background: rgba(5, 30, 48, 0.52);
transition: border-color 0.18s ease, background 0.18s ease;
.el-radio__label {
display: grid;
gap: 4px;
padding-left: 9px;
white-space: normal;
}
strong {
color: #f2fbff;
font-weight: 800;
}
span {
color: #9fc6da;
font-size: 12px;
line-height: 1.55;
}
&.is-checked {
border-color: rgba(100, 226, 255, 0.82);
background: rgba(8, 109, 151, 0.38);
}
}
.custom-scene-input {
margin-top: 10px;
}
.product-name-field {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
align-items: center;
gap: 10px;
margin-bottom: 14px;
color: #dff5ff;
font-size: 14px;
font-weight: 800;
}
.image-upload-trigger {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
padding: 0 18px;
border: 1px dashed rgba(104, 224, 255, 0.75);
border-radius: 12px;
color: #e8fbff;
background: rgba(13, 107, 151, 0.34);
font-weight: 800;
cursor: pointer;
transition: background 0.18s ease, transform 0.18s ease;
input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
&:hover:not(.disabled) {
transform: translateY(-1px);
background: rgba(21, 144, 194, 0.48);
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.product-image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
margin-top: 14px;
}
.product-image-card {
display: grid;
gap: 7px;
min-width: 0;
margin: 0;
padding: 8px;
border: 1px solid rgba(79, 169, 217, 0.35);
border-radius: 12px;
background: rgba(1, 18, 31, 0.62);
figcaption {
overflow: hidden;
color: #b9d9e8;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.product-image-preview {
aspect-ratio: 1.2;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 8px;
background: #06131d;
cursor: zoom-in;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.remove-image-button {
justify-self: start;
padding: 0;
border: 0;
color: #ff9eaf;
background: transparent;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.product-image-full {
display: block;
width: 100%;
max-height: 72vh;
object-fit: contain;
}
.training-table-wrap {
overflow-x: auto;
border: 1px solid rgba(0, 180, 255, 0.22);
@ -1249,6 +1598,61 @@ function exportOutcome() {
background: rgba(13, 64, 88, 0.62);
}
.classification-table-wrap {
margin-top: 20px;
}
.classification-table th:first-child,
.classification-table td:first-child {
width: 84px;
text-align: center;
}
.classification-table th:last-child,
.classification-table td:last-child {
width: 240px;
}
.classification-number {
color: #9bdcff;
font-weight: 800;
}
.classification-selling-point {
line-height: 1.65;
}
.classification-select-cell :deep(.el-select) {
width: 100%;
}
.classification-row--correct td {
background: rgba(23, 181, 121, 0.12);
}
.classification-row--incorrect td {
background: rgba(239, 91, 91, 0.13);
}
.classification-feedback,
.classification-summary {
margin: 8px 0 0;
color: #ffb1b1;
font-size: 12px;
line-height: 1.45;
}
.classification-row--correct .classification-feedback,
.classification-summary.is-success {
color: #86e7bd;
}
.classification-summary {
margin-top: 16px;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 1100px) {
.new-product-value-page {
grid-template-columns: 1fr;
@ -1312,6 +1716,10 @@ function exportOutcome() {
.price-options {
grid-template-columns: 1fr;
}
.classification-table {
min-width: 720px;
}
}
</style>

@ -0,0 +1,30 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const page = fs.readFileSync(path.join(root, "src/views/foundation/new-product-survey.vue"), "utf8");
const api = fs.readFileSync(path.join(root, "src/api/studentTrainingAnswer.js"), "utf8");
const materialButton = fs.readFileSync(path.join(root, "src/views/components/TrainingMaterialButton.vue"), "utf8");
assert.match(page, /const STEP_ONE_SELLING_POINTS = \[/, "step one must define its fixed selling-point questions");
assert.ok((page.match(/selling-point-/g) || []).length >= 11, "step one must contain all 11 selling points");
assert.match(page, /核心层/, "classification options must include 核心层");
assert.match(page, /有形层/, "classification options must include 有形层");
assert.match(page, /延伸层/, "classification options must include 延伸层");
assert.match(page, /checkNewProductSurveyStepOne/, "step one must request server-side grading");
assert.match(page, /checkResult/, "step one must retain grading feedback");
assert.match(page, /TrainingMaterialButton/, "the existing material download button must remain in place");
assert.match(api, /new-product-survey\/step-1\/check/, "the student API must call the step-one grading endpoint");
assert.match(page, /const STEP_TWO_PLATFORM_OPTIONS = \[/, "step two must offer the prescribed survey platforms");
assert.match(page, /const STEP_TWO_SCENE_OPTIONS = \[/, "step two must offer the prescribed survey scenes");
assert.match(page, /v-if="step2Form\.scene === '其他场景'"/, "other scenes must reveal a custom-scene input");
assert.match(page, /MAX_STEP_TWO_IMAGES = 5/, "step two must cap product image uploads at five");
assert.match(page, /@click="previewStepTwoImage\(image\.url\)"/, "uploaded product thumbnails must be previewable");
assert.match(page, /step2Answer: JSON\.stringify\(step2Form\.value\)/, "step two must persist the survey form instead of table rows");
assert.match(page, /checkNewProductSurveyStepTwo\(step2Form\.value\)/, "step two must request server-side validation before advancing");
assert.match(api, /new-product-survey\/step-2\/validate/, "the student API must call the step-two validation endpoint");
assert.match(materialButton, /下载案例资料/, "the download button must use a fixed student-facing label");
assert.doesNotMatch(materialButton, /\{\{ material\?\.name \|\| "案例文档" \}\}/, "the upload file name must not become button text");
console.log("new product survey step-one contract passed");
Loading…
Cancel
Save