diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index a2a8072..311f1ed 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -94,3 +94,43 @@ export function checkNewProductSurveyStepOne(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 }, + }); +} diff --git a/src/views/foundation/new-product-survey.vue b/src/views/foundation/new-product-survey.vue index 62f7d16..aa52aa4 100644 --- a/src/views/foundation/new-product-survey.vue +++ b/src/views/foundation/new-product-survey.vue @@ -92,33 +92,57 @@

Step 02

{{ getStepName(2) }}

- 平台与关键词 + 实地调研记录 -

- 优先选择京东、天猫、抖音等主流电商平台,至少选择 3 个高流量使用场景,采用“核心词 + 属性词 + 场景词”的电商搜索热词逻辑记录产品。 -

- -
- - - - - - - - - - - - - - - - - - - -
使用场景搜索平台搜索关键词(电商热词)平台类目销量参考(近30天)
+

请以真实消费者身份进入电商平台,通过搜索、浏览和对比,采集 1 个 AI 产品的真实数据,并完成产品三层次分析。

+ +
+
+

01 选择调研平台

+ + + {{ option.label }}{{ option.description }} + + +
+ +
+

02 确定调研场景

+ + + {{ option.label }}{{ option.description }} + + + +
+ +
+

03 设计搜索关键词

+

遵循“核心词 + 属性词 + 场景词”。例如:“AI智能音箱 语音控制 家居”“AI办公鼠标 语音转写 会议”。

+ +
+ +
+

04 记录产品信息并上传图片

+

请在搜索结果中按“销量”排序,从排名前 10 的产品中选择 1 款产品;上传主图及详情页截图,最多 5 张。

+ + +
+
+ +
{{ image.name }}
+ +
+
+
@@ -194,11 +218,11 @@ 上一步 - - @@ -217,6 +241,10 @@ + + 产品图片大图 + + { }); 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 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 STEP_ONE_SELLING_POINTS = [ { id: "selling-point-1", text: "生日礼物女孩子毕业儿童3到6岁10小女童" }, @@ -308,11 +355,7 @@ const STEP_ONE_SELLING_POINTS = [ { id: "selling-point-11", text: "店铺优惠后169元起" }, ]; -const createStep2Rows = () => [ - { scene: "", platform: "", keyword: "", category: "", sales: "" }, - { scene: "", platform: "", keyword: "", category: "", sales: "" }, - { scene: "", platform: "", keyword: "", category: "", sales: "" }, -]; +const createStep2Form = () => ({ platform: "", scene: "", customScene: "", keyword: "", productName: "", images: [] }); const createStep3Rows = () => [ { @@ -349,7 +392,7 @@ const createStep4Form = () => ({ const classifications = ref({}); const checkResult = ref(null); -const step2Rows = ref(createStep2Rows()); +const step2Form = ref(createStep2Form()); const step3Rows = ref(createStep3Rows()); const step4Form = ref(createStep4Form()); @@ -362,7 +405,7 @@ const getLocalDraftKey = () => { const currentStudyTip = computed(() => { const tips = { 1: "当前步骤建议:先从案例或电商详情页中拆出核心层、有形层、延伸层,不要把功能和用户利益混在一起。", - 2: "当前步骤建议:选择主流电商平台,并用“核心词 + 属性词 + 场景词”的组合记录关键词和销量参考。", + 2: "当前步骤建议:选择一个主流电商平台与真实 AI 产品,用“核心词 + 属性词 + 场景词”搜索并保留主图、详情页截图。", 3: "当前步骤建议:同一维度横向比较 3 个产品,重点找出 AI 技术在哪一层创造了差异化。", 4: "当前步骤建议:把前面三步的发现收束为产品开发启发,说明哪些价值值得继续强化。", }; @@ -384,7 +427,7 @@ onMounted(async () => { }); watch( - [classifications, checkResult, step2Rows, step3Rows, step4Form, currentStep], + [classifications, checkResult, step2Form, step3Rows, step4Form, currentStep], () => persistDraft(), { deep: true } ); @@ -450,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; @@ -464,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); @@ -521,11 +564,58 @@ 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; } @@ -583,7 +673,7 @@ function buildAnswerPayload(status) { saveAction, currentStep: currentStep.value, step1Answer: JSON.stringify({ classifications: classifications.value, checkResult: checkResult.value }), - step2Answer: JSON.stringify(step2Rows.value), + step2Answer: JSON.stringify(step2Form.value), step3Answer: JSON.stringify(step3Rows.value), step4Answer: JSON.stringify(step4Form.value), }; @@ -610,6 +700,20 @@ function submitTask() { } 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; @@ -651,7 +755,9 @@ function prevStep() { function resetExample() { classifications.value = {}; checkResult.value = null; - step2Rows.value = createStep2Rows(); + step2Form.value = createStep2Form(); + imagePreviewUrl.value = ""; + imagePreviewOpen.value = false; step3Rows.value = createStep3Rows(); step4Form.value = createStep4Form(); currentStep.value = 1; @@ -667,7 +773,7 @@ function buildOutcome() { steps: steps.value.map((item) => item.name), classifications: classifications.value, checkResult: checkResult.value, - step2Rows: step2Rows.value, + step2Form: step2Form.value, step3Rows: step3Rows.value, step4Form: step4Form.value, }; @@ -1165,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); diff --git a/tests/new-product-survey-step-one.static.test.cjs b/tests/new-product-survey-step-one.static.test.cjs index b47160e..e593bc5 100644 --- a/tests/new-product-survey-step-one.static.test.cjs +++ b/tests/new-product-survey-step-one.static.test.cjs @@ -16,6 +16,14 @@ assert.match(page, /checkNewProductSurveyStepOne/, "step one must request server 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");