|
|
|
|
@ -50,49 +50,13 @@
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label class="field-block product-field">
|
|
|
|
|
<span>产品名称:</span>
|
|
|
|
|
<input v-model="form.productName" type="text" />
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<h4>需求转化:从“用户语言”到“工程语言”</h4>
|
|
|
|
|
<p>用户往往只描述现象或愿望,研发团队需要将其转化为可执行、可验收的技术指标。</p>
|
|
|
|
|
|
|
|
|
|
<div class="example-block">
|
|
|
|
|
<h5>需求转化过程示例:</h5>
|
|
|
|
|
<p><strong>1. 场景还原与痛点提炼</strong></p>
|
|
|
|
|
<p>不要只听用户说“我想要个能陪孩子的机器人”,而要深入场景。</p>
|
|
|
|
|
<p class="indent">用户原话:“孩子在家总看电视,我希望机器人能陪他玩,还能教我孩子学英语。”</p>
|
|
|
|
|
<p class="indent">场景洞察:家长(30-40 岁)双职工,下班晚,担心孩子视力及教育缺失;孩子(3-6 岁)注意力短,需要强互动。</p>
|
|
|
|
|
<p class="indent">转化动作:将“陪玩”转化为多模态交互能力(语音 + 视觉 + 运动);将“学英语”转化为内容生态接入与个性化推荐算法。</p>
|
|
|
|
|
<p><strong>2. 标准化需求定义(PRD 要素)</strong></p>
|
|
|
|
|
<p>将洞察转化为开发团队能看懂的文档,必须包含验收标准。</p>
|
|
|
|
|
<p class="indent">需求名称:主动式英语启蒙互动。</p>
|
|
|
|
|
<p class="indent">功能描述:当摄像头识别到孩子注视屏幕超过 10 分钟,或检测到孩子无聊发呆时,机器人主动发起英语对话或游戏邀请。</p>
|
|
|
|
|
<p class="indent">验收标准:</p>
|
|
|
|
|
<p class="indent">识别响应时间 < 1 秒;</p>
|
|
|
|
|
<p class="indent">对话自然度(MOS 分)> 4.0;</p>
|
|
|
|
|
<p class="indent">内容准确率由教育专家评估 > 90%。</p>
|
|
|
|
|
<div class="conversion-intro">
|
|
|
|
|
<p>用户反馈通常混杂重复描述、情绪化表达、无关信息。请阅读案例资料中原始用户反馈,逐条进行场景洞察拆解,分析用户在什么时间、什么情境下产生该需求,背后的深层动机是什么,然后将用户语言转化为可执行的产品功能定义,填写以下需求转化表。</p>
|
|
|
|
|
<div class="conversion-row-actions">
|
|
|
|
|
<el-button type="primary" :disabled="form.rows.length >= MAX_CONVERSION_ROWS" @click="addConversionRow">增加一行</el-button>
|
|
|
|
|
<el-button type="primary" plain :disabled="form.rows.length <= MIN_CONVERSION_ROWS" @click="removeConversionRow">删除一行</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p class="source-title">原始需求基础材料:</p>
|
|
|
|
|
<div class="feedback-grid">
|
|
|
|
|
<section>
|
|
|
|
|
<h5>家长需求反馈:</h5>
|
|
|
|
|
<ol>
|
|
|
|
|
<li v-for="item in parentFeedback" :key="item">{{ item }}</li>
|
|
|
|
|
</ol>
|
|
|
|
|
</section>
|
|
|
|
|
<section>
|
|
|
|
|
<h5>孩子需求反馈:</h5>
|
|
|
|
|
<ol>
|
|
|
|
|
<li v-for="item in childFeedback" :key="item">{{ item }}</li>
|
|
|
|
|
</ol>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p class="table-tip">请根据本题需求,填写以下需求转化表:</p>
|
|
|
|
|
<div class="conversion-table-wrap">
|
|
|
|
|
<table class="conversion-table">
|
|
|
|
|
<thead>
|
|
|
|
|
@ -108,15 +72,22 @@
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="(row, index) in form.rows" :key="row.id">
|
|
|
|
|
<td>{{ index + 1 }}</td>
|
|
|
|
|
<td><textarea v-model="row.voice" placeholder="填写用户原话"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.insight" placeholder="填写场景与痛点洞察"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.definition" placeholder="填写转化后的功能定义"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.description" placeholder="填写功能描述"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.acceptance" placeholder="填写技术指标或验收标准"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.voice" :placeholder="index === 0 ? '示例:起夜需要温和照明,避免强光刺激' : '填写用户原声'"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.insight" :placeholder="index === 0 ? '示例:凌晨2-5点用户从深度睡眠中被尿意唤醒,处于半梦半醒状态,瞳孔对光线极其敏感' : '填写场景洞察'"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.definition" :placeholder="index === 0 ? '示例:起夜微光模式' : '填写转化后的功能定义'"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.description" :placeholder="index === 0 ? '示例:感应到用户起身时,自动开启亮度≤10%的暖黄地脚光,延迟3秒渐亮' : '填写功能描述'"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.acceptance" :placeholder="index === 0 ? '示例:亮度5-15流明可调,色温2700K,启动延时≤0.5秒,光通量渐升时间3±0.5秒' : '填写技术指标/验收标准'"></textarea></td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="conversion-limit-tip">至少 {{ MIN_CONVERSION_ROWS }} 行,最多 {{ MAX_CONVERSION_ROWS }} 行</p>
|
|
|
|
|
<div class="conversion-helper">
|
|
|
|
|
<h5>可参考以下方法:</h5>
|
|
|
|
|
<p>1. 自行阅读案例信息,将用户原声转化为需求,再通过需求分析转化为产品功能。</p>
|
|
|
|
|
<p>2. 使用通用 AI 大模型辅助完成场景洞察与功能转化,参考提示词见案例文档。</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p v-if="conversionValidationMessage" class="conversion-validation-message" role="status">{{ conversionValidationMessage }}</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(2)" class="step-card conversion-content priority-content">
|
|
|
|
|
@ -281,9 +252,11 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { checkFeatureConversionStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
|
|
|
|
|
const TASK_KEY = "feature-conversion";
|
|
|
|
|
const MIN_CONVERSION_ROWS = 8;
|
|
|
|
|
const MAX_CONVERSION_ROWS = 12;
|
|
|
|
|
const { proxy } = getCurrentInstance();
|
|
|
|
|
|
|
|
|
|
const defaultRequirement =
|
|
|
|
|
@ -350,9 +323,11 @@ const childFeedback = [
|
|
|
|
|
|
|
|
|
|
const taskConfig = ref(null);
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
const checkingConversion = ref(false);
|
|
|
|
|
const conversionValidationMessage = ref("");
|
|
|
|
|
|
|
|
|
|
function createRows() {
|
|
|
|
|
return Array.from({ length: 5 }, (_, index) => ({
|
|
|
|
|
function createRows(count = MIN_CONVERSION_ROWS) {
|
|
|
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
|
|
|
id: index + 1,
|
|
|
|
|
voice: "",
|
|
|
|
|
insight: "",
|
|
|
|
|
@ -408,7 +383,6 @@ const hasConfiguredSteps = computed(() => flowSteps.value.length > 0);
|
|
|
|
|
const activeStepName = computed(() => flowSteps.value[form.value.activeStep - 1] || "");
|
|
|
|
|
|
|
|
|
|
const progressItems = computed(() => [
|
|
|
|
|
{ text: form.value.productName ? "已填写:产品名称" : "待填写:产品名称", status: form.value.productName ? "done" : "" },
|
|
|
|
|
{ text: hasTableContent.value ? "进行中:需求转化表" : "待完成:需求转化表", status: hasTableContent.value ? "doing" : "" },
|
|
|
|
|
{ text: hasScoreContent.value ? "进行中:优先级打分表" : "待完成:优先级打分表", status: hasScoreContent.value ? "doing" : "" },
|
|
|
|
|
{ text: hasDecisionContent.value ? "进行中:优先级排序决策" : "待完成:优先级排序决策", status: hasDecisionContent.value ? "doing" : "" },
|
|
|
|
|
@ -482,20 +456,22 @@ function normalizeActiveStep(value) {
|
|
|
|
|
return Math.min(Math.max(parsed, 1), flowSteps.value.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setCurrentStep(step) {
|
|
|
|
|
form.value.activeStep = normalizeActiveStep(step);
|
|
|
|
|
async function setCurrentStep(step) {
|
|
|
|
|
const targetStep = normalizeActiveStep(step);
|
|
|
|
|
if (targetStep > form.value.activeStep && form.value.activeStep === 1 && !(await persist("IN_PROGRESS"))) return;
|
|
|
|
|
form.value.activeStep = targetStep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSectionVisible(section) {
|
|
|
|
|
return !hasConfiguredSteps.value || form.value.activeStep === section;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goPreviousStep() {
|
|
|
|
|
setCurrentStep(form.value.activeStep - 1);
|
|
|
|
|
async function goPreviousStep() {
|
|
|
|
|
await setCurrentStep(form.value.activeStep - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goNextStep() {
|
|
|
|
|
setCurrentStep(form.value.activeStep + 1);
|
|
|
|
|
async function goNextStep() {
|
|
|
|
|
await setCurrentStep(form.value.activeStep + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySavedAnswer(value) {
|
|
|
|
|
@ -517,11 +493,41 @@ function applySavedAnswer(value) {
|
|
|
|
|
|
|
|
|
|
function normalizeRows(rows = []) {
|
|
|
|
|
const savedRows = Array.isArray(rows) ? rows : [];
|
|
|
|
|
return createRows().map((row, index) => ({
|
|
|
|
|
...row,
|
|
|
|
|
...(savedRows[index] || {}),
|
|
|
|
|
id: row.id,
|
|
|
|
|
}));
|
|
|
|
|
const normalizedCount = Math.min(Math.max(savedRows.length, MIN_CONVERSION_ROWS), MAX_CONVERSION_ROWS);
|
|
|
|
|
return createRows(normalizedCount).map((row, index) => ({ ...row, ...(savedRows[index] || {}), id: row.id }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addConversionRow() {
|
|
|
|
|
if (form.value.rows.length >= MAX_CONVERSION_ROWS) {
|
|
|
|
|
proxy?.$modal?.msgWarning(`最多只能填写 ${MAX_CONVERSION_ROWS} 行`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nextId = Math.max(0, ...form.value.rows.map((row) => Number(row.id) || 0)) + 1;
|
|
|
|
|
form.value.rows.push({ id: nextId, voice: "", insight: "", definition: "", description: "", acceptance: "" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeConversionRow() {
|
|
|
|
|
if (form.value.rows.length <= MIN_CONVERSION_ROWS) {
|
|
|
|
|
proxy?.$modal?.msgWarning(`至少需要填写 ${MIN_CONVERSION_ROWS} 行`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
form.value.rows.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureConversionValid() {
|
|
|
|
|
if (checkingConversion.value) return false;
|
|
|
|
|
checkingConversion.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const response = await checkFeatureConversionStepOne({ rows: form.value.rows });
|
|
|
|
|
const result = response?.data || {};
|
|
|
|
|
conversionValidationMessage.value = result.message || (result.valid ? "需求转化表填写完整。" : `请完整填写 ${MIN_CONVERSION_ROWS}-${MAX_CONVERSION_ROWS} 行需求转化信息。`);
|
|
|
|
|
return result.valid === true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
conversionValidationMessage.value = error?.message || "需求转化表校验失败,请稍后重试。";
|
|
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
checkingConversion.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeScoreRows(rows = []) {
|
|
|
|
|
@ -566,10 +572,12 @@ function buildAnswerPayload(status) {
|
|
|
|
|
|
|
|
|
|
async function persist(status = "IN_PROGRESS") {
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureConversionValid())) return false;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
|
|
|
|
|
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
|
|
|
|
|
return true;
|
|
|
|
|
} finally {
|
|
|
|
|
saving.value = false;
|
|
|
|
|
}
|
|
|
|
|
@ -789,6 +797,35 @@ function exportOutcome() {
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-intro {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 22px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
border-left: 3px solid #51dfff;
|
|
|
|
|
border-radius: 0 14px 14px 0;
|
|
|
|
|
background: linear-gradient(90deg, rgba(7, 95, 137, 0.28), rgba(4, 31, 49, 0.18));
|
|
|
|
|
|
|
|
|
|
> p {
|
|
|
|
|
max-width: 840px;
|
|
|
|
|
color: #c6dee9;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.85;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-row-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
|
|
.el-button {
|
|
|
|
|
min-width: 112px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.indent {
|
|
|
|
|
padding-left: 24px;
|
|
|
|
|
}
|
|
|
|
|
@ -878,6 +915,40 @@ function exportOutcome() {
|
|
|
|
|
color: #8fa4b1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-limit-tip {
|
|
|
|
|
margin: 12px 0 0 !important;
|
|
|
|
|
color: #ff958a;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-helper {
|
|
|
|
|
margin-top: 26px;
|
|
|
|
|
padding: 16px 18px;
|
|
|
|
|
border: 1px solid rgba(68, 163, 208, 0.3);
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
color: #b7d1de;
|
|
|
|
|
background: rgba(3, 27, 43, 0.4);
|
|
|
|
|
|
|
|
|
|
h5 {
|
|
|
|
|
margin: 0 0 4px;
|
|
|
|
|
color: #e8f9ff;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-validation-message {
|
|
|
|
|
margin: 14px 0 0 !important;
|
|
|
|
|
color: #ffbbb0;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.priority-content {
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
}
|
|
|
|
|
@ -1645,6 +1716,16 @@ function exportOutcome() {
|
|
|
|
|
.step-navigation {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-intro,
|
|
|
|
|
.conversion-row-actions {
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.conversion-row-actions .el-button {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.workbench-head--actions-only {
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|