feat: collect new product survey step two

dev-QQq
chenyuan 3 weeks ago
parent c0e378b3df
commit 2c92dc2836

@ -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 },
});
}

@ -92,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>
<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>
<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>
<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>
@ -194,11 +218,11 @@
<el-icon><ArrowLeft /></el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving || checking" @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 || saving || checking" @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>
@ -217,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="产品三层次分析参考"
@ -241,7 +269,7 @@ import {
TrendCharts,
} from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkNewProductSurveyStepOne, 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";
@ -255,6 +283,9 @@ 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);
@ -293,6 +324,22 @@ const steps = computed(() => {
});
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);

@ -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");

Loading…
Cancel
Save