You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/foundation/new-product-survey.vue

2473 lines
80 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="new-product-value-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul>
<li v-for="goal in taskGoals" :key="goal">{{ goal }}</li>
</ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-export="exportOutcome" />
</section>
<section class="task-card">
<nav class="step-indicator" aria-label="新产品调查与分析步骤">
<button v-for="step in steps" :key="step.no" type="button"
:class="['step-tab', { active: currentStep === step.no, complete: currentStep > step.no }]"
:aria-label="`切换至第 ${step.no} 步:${step.name}`"
:aria-current="currentStep === step.no ? 'step' : undefined" :title="step.name"
@click="currentStep = step.no">
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(step.no).padStart(2, "0") }}</span>
<span class="step-name">{{ step.name }}</span>
</button>
</nav>
<section v-show="currentStep === 1" class="step-content active-step">
<div class="step-card">
<div class="step-title-row">
<div class="step-title-main">
<p class="section-eyebrow">Step 01</p>
<h3>{{ getStepName(1) }}</h3>
</div>
<span class="step-chip">卖点分类练习</span>
</div>
<p class="step-desc">查看案例产品详情,仔细阅读主图及详情页信息,对下列产品卖点进行三层次分类。</p>
<TrainingStepOneChoiceQuestions :questions="taskConfig?.stepOneQuestions"
v-model="stepOneChoiceAnswers" />
<!-- 表格 UI 已注销:第一步只保留知识自测,原三层次分类表格 + 判题摘要保留代码以便日后恢复
<div class="training-table-wrap classification-table-wrap">
<table class="training-data-table classification-table">
<thead>
<tr>
<th>序号</th>
<th>卖点</th>
<th>
三层次分类
<span class="layer-legend" aria-hidden="true">
<i style="--dot: var(--student-layer-1)"></i>
<i style="--dot: var(--student-layer-2)"></i>
<i style="--dot: var(--student-layer-3)"></i>
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(point, index) in STEP_ONE_SELLING_POINTS" :key="point.id"
:class="[feedbackClass(point.id), layerClass(classifications[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>
<p v-if="checkResult" class="classification-summary"
:class="{ 'is-success': checkResult.allCorrect }">
本次判题:{{ checkResult.correctCount }} / {{ checkResult.totalCount }} 项分类正确
</p>
-->
</div>
</section>
<section v-show="currentStep === 2" class="step-content active-step">
<div class="step-card step-two-survey">
<div class="step-title-row">
<div class="step-title-main">
<p class="section-eyebrow">Step 02</p>
<h3>{{ getStepName(2) }}</h3>
</div>
<span class="step-chip">实地调研记录</span>
</div>
<p class="step-desc">请以真实消费者身份进入电商平台,通过搜索、浏览和对比,采集 1 个 AI 产品的真实数据,并完成产品三层次分析。</p>
<div class="survey-form">
<section class="survey-field-group">
<div class="section-heading"><span>01</span>
<div>
<h3>选择调研平台</h3>
</div>
</div>
<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">
<div class="section-heading"><span>02</span>
<div>
<h3>确定调研场景</h3>
</div>
</div>
<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">
<div class="section-heading"><span>03</span>
<div>
<h3>设计搜索关键词</h3>
<p>遵循"核心词 + 属性词 + 场景词"。例如:"AI智能音箱 语音控制 家居""AI办公鼠标 语音转写 会议"。</p>
</div>
</div>
<input v-model.trim="step2Form.keyword" class="input-single" placeholder="请输入搜索关键词"
maxlength="120" />
</section>
<section class="survey-field-group">
<div class="section-heading"><span>04</span>
<div>
<h3>记录产品信息并上传图片</h3>
<p>请在搜索结果中按"销量"排序,从排名前 10 的产品中选择 1 款产品;上传主图及详情页截图,最多 5 张。</p>
</div>
</div>
<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="resolveUploadedImageUrl(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>
<section v-show="currentStep === 3" class="step-content active-step">
<div class="step-card">
<div class="step-title-row">
<div class="step-title-main">
<p class="section-eyebrow">Step 03</p>
<h3>{{ getStepName(3) }}</h3>
</div>
<span class="step-chip">三层次对照分析</span>
</div>
<p class="step-desc">
基于上一步采集的真实数据,运用"三层次理论"进行深度分析,参考示例填写下表;每层至少填写 2 条要点并按换行分条。
</p>
<div class="training-table-wrap">
<table class="training-data-table layer-table">
<thead>
<tr>
<th>分析维度</th>
<th>实例:AI学习机</th>
<th>{{ step2Form.productName || '所选产品' }}分析</th>
</tr>
</thead>
<tbody>
<tr v-for="row in step3Rows" :key="row.dimension">
<td class="dimension-cell"><strong>{{ row.dimension }}</strong><span>{{
row.definition }}</span></td>
<td class="example-cell">
<ol class="reference-points">
<li v-for="point in row.referencePoints" :key="point">{{ point }}</li>
</ol>
</td>
<td class="analysis-input-cell">
<textarea v-model="row.analysis" class="cell-textarea" rows="7"
placeholder="每行填写一条分析要点" />
<p class="analysis-count"
:class="{ 'is-ready': countStepThreePoints(row.analysis) >= 2 }">已填写 {{
countStepThreePoints(row.analysis) }} / 至少 2 条</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section v-show="currentStep === 4" class="step-content active-step">
<div class="step-card">
<div class="step-title-row">
<div class="step-title-main">
<p class="section-eyebrow">Step 04</p>
<h3>{{ getStepName(4) }}</h3>
</div>
<span class="step-chip">价值判断</span>
</div>
<p class="step-desc">结合上面实训,针对AI技术在产品中的应用价值进行分析,回答以下问题。</p>
<label class="field-block is-layer layer-core">
<span><b><span class="step4-no">01</span>核心层:</b>AI技术是否实现产品功能突破?是否形成差异化卖点?</span>
<textarea v-model="step4Form.coreValue" class="input-field step4-input" rows="7"
placeholder="请填写核心层AI应用价值分析" />
</label>
<label class="field-block is-layer layer-tangible">
<span><b><span class="step4-no">02</span>有形层:</b>AI技术是否影响产品形态?如产品外形包装、电商主图设计、参数配置、价格带定位。</span>
<textarea v-model="step4Form.tangibleValue" class="input-field step4-input" rows="7"
placeholder="请填写有形层AI应用价值分析" />
</label>
<label class="field-block is-layer layer-extended">
<span><b><span class="step4-no">03</span>延伸层:</b>AI技术如何通过服务升级、内容更新提升用户粘性,推动付费服务?</span>
<textarea v-model="step4Form.extendedValue" class="input-field step4-input" rows="7"
placeholder="请填写延伸层AI应用价值分析" />
</label>
</div>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" @click="prevStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving || checking || uploadingImages"
@click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(currentStep).padStart(2, "0") }} / {{
String(steps.length).padStart(2, "0") }}</span>
<button v-if="currentStep < steps.length" type="button" class="btn-nav btn-primary"
:disabled="saving || checking || uploadingImages" @click="nextStep">
{{ checking ? "正在判题" : "下一步" }}
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</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="产品三层次分析参考"
reference-text="· 核心层:用户购买的根本利益与真实痛点<br />· 有形层:功能、外观、品牌、包装、参数等可感知内容<br />· 延伸层:售后、内容服务、会员权益、生态兼容、数据安全等附加价值"
assist-text="建议先把电商页面中的主图、参数、详情页、评价和售后承诺分别归入三层次,再比较哪些层次能形成差异化。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import {
ArrowLeft,
ArrowRight,
CircleCheck,
DataAnalysis,
Operation,
Opportunity,
RefreshLeft,
Search,
TrendCharts,
} from "@element-plus/icons-vue"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { checkNewProductSurveyStepFour, checkNewProductSurveyStepOne, checkNewProductSurveyStepThree, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import useUserStore from "@/store/modules/user"
import { isExternal } from "@/utils/validate"
import { extractTrainingStepNames } from "@/views/training/taskKeyMap"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import TrainingStepOneChoiceQuestions from "@/views/components/TrainingStepOneChoiceQuestions.vue"
const TASK_KEY = "new-product-survey"
const { proxy } = getCurrentInstance()
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 briefOpen = ref(true)
const defaultSteps = [
{ no: 1, name: "产品卖点的三层次分类", icon: Search },
{ no: 2, name: "选定调查平台与产品", icon: DataAnalysis },
{ no: 3, name: "AI产品三层次分析", icon: TrendCharts },
{ no: 4, name: "AI应用价值分析", icon: Operation },
]
const defaultGoals = [
"理解市场驱动、技术推动等不同类型产品开发的核心逻辑,培养市场意识,能分析新技术在互联网产品中的应用场景。",
"掌握运用电商平台开展市场调查的方法,能结合产品三层次理论分析互联网新产品。",
"能通过分析,理解企业如何通过不同产品层次创造差异化竞争优势。",
]
const taskTitle = computed(() => taskConfig.value?.taskName || "新产品调查与分析")
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals))
const taskBackground = computed(
() =>
taskConfig.value?.background ||
"2024年,生成式AI技术爆发式发展,催生大量消费端新产品,如AI智能音箱、AI学习机、AI玩具等。DeepSeek的出现降低了国内企业使用AI技术开发产品的成本,企业希望借助电商平台数据,为未来产品开发提供思路。"
)
const taskRequirement = computed(
() =>
taskConfig.value?.requirements ||
"请借助电商平台完成新产品调查,围绕产品三层次提取核心层、有形层、延伸层信息,并结合AI技术价值形成分析结论。"
)
const steps = computed(() => {
const configured = extractTrainingStepNames(taskConfig.value?.steps, defaultSteps.map((item) => item.name)).slice(0, 4)
return defaultSteps.map((step, index) => ({
...step,
name: configured[index] || step.name,
}))
})
const LAYER_OPTIONS = ["核心层", "有形层", "延伸层"]
/* 纯 UI 辅助:按已选层次为表格行上色,不影响判题数据 */
const LAYER_CLASS_MAP = { "核心层": "is-layer-core", "有形层": "is-layer-tangible", "延伸层": "is-layer-extended" }
function layerClass(value) {
return LAYER_CLASS_MAP[value] || ""
}
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小女童" },
{ 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 STEP_THREE_REFERENCE_ROWS = [
{ dimension: "核心层", definition: "AI技术解决的核心需求/功能", referencePoints: ["大模型作业批改", "自适应学习路径规划"] },
{ dimension: "有形层", definition: "外观/材质/参数/价格策略", referencePoints: ["护眼屏+铝合金机身", "搭载NPU芯片", "全平台统一定价2999元,某网红直播间专属价2699元"] },
{ dimension: "延伸层", definition: "软件升级/专属服务/社群运营", referencePoints: ["终身免费题库更新", "VIP学科规划师1对1", "家长互助社群及资料共享微信群"] },
]
const createStep3Rows = () => STEP_THREE_REFERENCE_ROWS.map((row) => ({ ...row, analysis: "" }))
const createStep4Form = () => ({
coreValue: "",
tangibleValue: "",
extendedValue: "",
})
const classifications = ref({})
const stepOneChoiceAnswers = ref({})
const checkResult = ref(null)
const step2Form = ref(createStep2Form())
const step3Rows = ref(createStep3Rows())
const step4Form = ref(createStep4Form())
const getLocalDraftKey = () => {
const userInfo = userStore.userInfo || {}
const userKey = userInfo.userId || userInfo.user_id || userInfo.username || userInfo.userName || "anonymous"
return `${TASK_KEY}:draft:${userKey}`
}
const currentStudyTip = computed(() => {
const tips = {
1: "当前步骤建议:先从案例或电商详情页中拆出核心层、有形层、延伸层,不要把功能和用户利益混在一起。",
2: "当前步骤建议:选择一个主流电商平台与真实 AI 产品,用“核心词 + 属性词 + 场景词”搜索并保留主图、详情页截图。",
3: "当前步骤建议:将上一步选定产品的卖点按核心层、有形层、延伸层拆分,每层至少记录两条可核对的分析要点。",
4: "当前步骤建议:把前面三步的发现收束为产品开发启发,说明哪些价值值得继续强化。",
}
return tips[currentStep.value]
})
const progressItems = computed(() =>
steps.value.map((step) => ({
text: `${currentStep.value > step.no ? "已完成" : currentStep.value === step.no ? "进行中" : "待完成"}:${step.name}`,
status: currentStep.value > step.no ? "done" : currentStep.value === step.no ? "doing" : "",
}))
)
onMounted(async () => {
loadDraft()
await loadTaskConfig()
await loadSavedAnswer()
draftReady.value = true
})
watch(
[classifications, checkResult, step2Form, step3Rows, step4Form, currentStep],
() => persistDraft(),
{ deep: true }
)
function parseArray(value, fallback) {
if (Array.isArray(value)) {
return value.length ? value : fallback
}
if (!value) {
return fallback
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) && parsed.length ? parsed : fallback
} catch (error) {
const list = String(value)
.split(/[;;|]/)
.map((item) => item.trim())
.filter(Boolean)
return list.length ? list : fallback
}
}
function getStepName(no) {
return steps.value.find((item) => item.no === no)?.name || defaultSteps[no - 1]?.name || ""
}
async function loadTaskConfig() {
try {
const res = await getTrainingTaskByKey(TASK_KEY)
taskConfig.value = res?.data || null
} catch (error) {
taskConfig.value = null
}
}
async function loadSavedAnswer() {
try {
const res = await getStudentTrainingAnswer(TASK_KEY)
const answer = res?.data
if (answer && applySavedAnswer(answer)) {
clearDraft()
}
} catch (error) {
// 后端不可用时,页面仍允许学生直接填写。
}
}
function safeParse(value) {
if (!value) return null
if (typeof value === "object") return value
try {
return JSON.parse(value)
} catch (error) {
return null
}
}
function applySavedAnswer(value) {
const parsed = safeParse(value)
if (!parsed || typeof parsed !== "object") return false
if (parsed.step1Answer || parsed.step2Answer || parsed.step3Answer || parsed.step4Answer) {
let loaded = false
const step1Parsed = safeParse(parsed.step1Answer)
if (step1Parsed?.step1Form || step1Parsed?.step2Form || step1Parsed?.step2Rows || step1Parsed?.step3Rows || step1Parsed?.step4Form) {
loaded = applySavedAnswer(step1Parsed) || loaded
} else {
loaded = applyStep1Answer(step1Parsed || parsed.step1Answer) || loaded
}
loaded = applyStep2Answer(parsed.step2Answer) || loaded
loaded = applyStep3Answer(parsed.step3Answer) || loaded
loaded = applyStep4Answer(parsed.step4Answer) || loaded
currentStep.value = Math.min(Math.max(Number(parsed.currentStep || currentStep.value || 1), 1), 4)
return loaded
}
let loaded = false
loaded = applyStep1Answer(parsed.step1Form || parsed) || 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)
return loaded
}
function applyStep1Answer(value) {
const parsed = safeParse(value)
if (!parsed || typeof parsed !== "object") return false
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
stepOneChoiceAnswers.value = parsed.choiceAnswers && typeof parsed.choiceAnswers === "object" ? parsed.choiceAnswers : {}
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 resolveUploadedImageUrl(url) {
const source = String(url || "").trim()
if (!source || source.startsWith("data:") || source.startsWith("blob:")) return source
if (!isExternal(source)) return source.startsWith("/") ? source : `/${source}`
try {
const parsed = new URL(source)
if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
return source
}
return source
}
function previewStepTwoImage(url) {
imagePreviewUrl.value = resolveUploadedImageUrl(url)
imagePreviewOpen.value = true
}
function applyStep2Answer(value) {
const parsed = safeParse(value)
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
}
function applyStep3Answer(value) {
const parsed = safeParse(value)
const rows = Array.isArray(parsed) ? parsed : parsed?.step3Rows || parsed?.rows || parsed?.layerRows
if (!Array.isArray(rows) || !rows.length) return false
const savedByDimension = rows.reduce((result, row) => {
if (row?.dimension) result[row.dimension] = row
return result
}, {})
step3Rows.value = createStep3Rows().map((row, index) => {
const saved = savedByDimension[row.dimension] || rows[index] || {}
const points = Array.isArray(saved.points) ? saved.points : []
const legacyValue = [saved.analysis, saved.product1, saved.product2, saved.product3].find((item) => String(item || "").trim())
return {
...row,
analysis: points.length ? points.map((item) => String(item || "").trim()).filter(Boolean).join("\n") : String(legacyValue || ""),
}
})
return true
}
function applyStep4Answer(value) {
const parsed = safeParse(value)
if (!parsed || typeof parsed !== "object") return false
const source = parsed.step4Form || parsed
const fields = Object.keys(createStep4Form())
const hasValue = fields.some((key) => source[key] !== undefined && source[key] !== null && source[key] !== "")
if (!hasValue) return false
step4Form.value = { ...createStep4Form(), ...pickFields(source, fields) }
return true
}
function pickFields(source, fields) {
return fields.reduce((target, key) => {
if (source[key] !== undefined && source[key] !== null) {
target[key] = source[key]
}
return target
}, {})
}
function normalizeRows(rows, fallback, matchKey = "") {
if (!Array.isArray(rows) || !rows.length) return fallback
if (matchKey) {
const rowMap = rows.reduce((map, row) => {
if (row?.[matchKey]) {
map[row[matchKey]] = row
}
return map
}, {})
return fallback.map((row, index) => ({
...row,
...(rowMap[row[matchKey]] || rows[index] || {}),
}))
}
return fallback.map((row, index) => ({
...row,
...(rows[index] || {}),
}))
}
function splitStepThreePoints(value) {
return String(value || "")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function countStepThreePoints(value) {
return splitStepThreePoints(value).length
}
function buildStepThreeValidationPayload() {
return {
rows: step3Rows.value.map((row) => ({
dimension: row.dimension,
points: splitStepThreePoints(row.analysis),
})),
}
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"
return {
saveAction,
currentStep: currentStep.value,
step1Answer: JSON.stringify({ classifications: classifications.value, checkResult: checkResult.value, choiceAnswers: stepOneChoiceAnswers.value }),
step2Answer: JSON.stringify(step2Form.value),
step3Answer: JSON.stringify(buildStepThreeValidationPayload()),
step4Answer: JSON.stringify(step4Form.value),
}
}
async function saveAnswer(status = "IN_PROGRESS", silent = false) {
saving.value = true
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status))
if (!silent) {
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功")
}
} finally {
saving.value = false
}
}
function saveCurrentProgress() {
return saveAnswer("IN_PROGRESS")
}
async function submitTask() {
checking.value = true
try {
await checkNewProductSurveyStepFour(step4Form.value)
await saveAnswer("SUBMITTED")
} catch (error) {
proxy?.$modal?.msgWarning(error?.message || "请完成三层次AI应用价值分析")
} finally {
checking.value = false
}
}
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 === 3) {
checking.value = true
try {
await checkNewProductSurveyStepThree(buildStepThreeValidationPayload())
await saveAnswer("IN_PROGRESS", true)
proxy?.$modal?.msgSuccess("第三步分析已校验并保存")
advanceStep()
} catch (error) {
proxy?.$modal?.msgWarning(error?.message || "请确保每个层次至少填写 2 条分析要点")
} finally {
checking.value = false
}
return
}
if (currentStep.value !== 1) {
advanceStep()
return
}
/* 第一步表格 UI 已注销:跳过表格判题校验,只校验知识自测 answers 是否已填 */
if (!Object.keys(stepOneChoiceAnswers.value || {}).length) {
proxy?.$modal?.msgWarning("请先完成知识自测后再进入下一步")
return
}
checking.value = true
try {
await saveAnswer("IN_PROGRESS", true)
proxy?.$modal?.msgSuccess("已保存,已进入下一步")
advanceStep()
} catch (error) {
proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试")
} finally {
checking.value = false
}
}
function advanceStep() {
if (currentStep.value < steps.value.length) {
currentStep.value += 1
}
}
function prevStep() {
if (currentStep.value > 1) {
currentStep.value -= 1
}
}
function resetExample() {
classifications.value = {}
stepOneChoiceAnswers.value = {}
checkResult.value = null
step2Form.value = createStep2Form()
imagePreviewUrl.value = ""
imagePreviewOpen.value = false
step3Rows.value = createStep3Rows()
step4Form.value = createStep4Form()
currentStep.value = 1
clearDraft()
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
taskName: taskTitle.value,
taskId: taskConfig.value?.id || "",
currentStep: currentStep.value,
steps: steps.value.map((item) => item.name),
classifications: classifications.value,
checkResult: checkResult.value,
choiceAnswers: stepOneChoiceAnswers.value,
step2Form: step2Form.value,
step3Rows: step3Rows.value,
step4Form: step4Form.value,
}
}
function persistDraft() {
if (!draftReady.value) return
try {
localStorage.setItem(getLocalDraftKey(), JSON.stringify(buildOutcome()))
} catch (error) {
// 本地草稿只是兜底,后端保存仍是主来源。
}
}
function loadDraft() {
try {
const raw = localStorage.getItem(getLocalDraftKey())
if (raw) {
applySavedAnswer(raw)
}
} catch (error) {
clearDraft()
}
}
function clearDraft() {
try {
localStorage.removeItem(getLocalDraftKey())
} catch (error) {
// 忽略浏览器存储异常。
}
}
function exportOutcome() {
const content = buildOutcome()
const blob = new Blob([JSON.stringify(content, null, 2)], { type: "application/json;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = "新产品调查与分析-实训成果.json"
link.click()
URL.revokeObjectURL(url)
}
</script>
<style lang="scss" scoped>
/* ============================================================
* 教育蓝 · 精炼文档风
* 纯白画布 + 教育蓝点缀 + 精致排版层次 + 克制的卡片设计
* ============================================================ */
.new-product-value-page {
/* 主色:教育蓝 */
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
/* 语义色 */
--student-danger: #ef4444;
--student-success: #2ba471;
/* 三层次标识色:深蓝 / 中蓝 / 浅蓝 */
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
/* 结构色 */
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
/* Element Plus 主色:教育蓝(覆盖全局 shell 写死的墨黑值) */
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
min-width: 0;
color: var(--student-text);
}
.training-core {
display: grid;
gap: 12px;
align-content: start;
}
/* ============================================================
* 任务头:白卡 + 左侧教育蓝竖条 + 标题 + 简介
* ============================================================ */
.task-header {
position: relative;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
line-height: 1.4;
font-weight: 700;
letter-spacing: -0.01em;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.65;
max-width: 100%;
}
.task-brief-text {
display: block;
}
.task-brief-toggle {
margin-left: 6px;
padding: 0;
border: 0;
color: var(--student-accent);
background: transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--student-accent-deep);
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e5e7eb;
b {
color: var(--student-text);
font-size: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 3px;
margin: 0;
padding-left: 18px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
}
/* ============================================================
* 步骤卡容器
* ============================================================ */
.task-card {
border: 0;
background: transparent;
}
/* ============================================================
* 步骤标题行:三段式(eyebrow + title + chip)
* ============================================================ */
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.step-title-main {
display: grid;
gap: 3px;
min-width: 0;
h3 {
margin: 0;
color: var(--student-text);
/* 全局 .step-title-row h3 以 !important 钉为 --student-fs-h2,此处与生效值保持一致 */
font-size: var(--student-fs-h2);
line-height: 1.4;
font-weight: 700;
letter-spacing: -0.01em;
}
}
.section-eyebrow {
display: inline-flex;
align-items: center;
margin: 0;
padding: 0;
color: var(--student-accent);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.step-chip {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 600;
white-space: nowrap;
}
.step-desc {
margin: 0 0 16px;
color: #4b5563;
font-size: var(--student-fs-body);
line-height: 1.7;
}
.step-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-card {
margin-bottom: 0;
padding: 18px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
}
/* ============================================================
* 次按钮基类
* ============================================================ */
.outline-action,
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
box-shadow: none;
}
}
/* ============================================================
* 输入框/文本域:扁平文档风
* ============================================================ */
.input-field,
.input-single,
.cell-input,
.cell-textarea {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-body);
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
}
.input-field {
min-height: 100px;
padding: 10px 12px;
resize: vertical;
line-height: 1.65;
}
.step4-input {
min-height: 150px;
}
.input-single {
min-height: 38px;
padding: 0 12px;
line-height: 1.5;
}
/* ============================================================
* Step 02: 调研表单 - 文档式字段组
* ============================================================ */
.survey-form {
display: grid;
gap: 12px;
}
.survey-field-group {
padding: 14px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #fafbfc;
}
.survey-options {
display: grid;
gap: 8px;
}
.step-two-survey {
.survey-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
:deep(.survey-option.el-radio) {
padding: 8px 10px;
.el-radio__label {
gap: 2px;
padding-left: 7px;
}
span {
font-size: var(--student-fs-caption);
line-height: 1.4;
}
}
}
: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 var(--student-panel-border);
border-radius: 8px;
color: #374151;
background: #ffffff;
transition: all 0.18s ease;
.el-radio__label {
display: grid;
gap: 3px;
padding-left: 8px;
white-space: normal;
}
strong {
color: var(--student-text);
font-weight: 700;
font-size: var(--student-fs-body);
}
span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.5;
}
&.is-checked {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
}
:deep(.survey-option.el-radio .el-radio__input.is-checked .el-radio__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
:deep(.survey-option.el-radio .el-radio__input.is-checked .el-radio__inner::after) {
background: #ffffff;
}
:deep(.survey-option.el-radio .el-radio__input .el-radio__inner) {
border-color: #9ca3af;
}
/* 全页单选统一教育蓝 */
.new-product-value-page :deep(.el-radio__input.is-checked .el-radio__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
.new-product-value-page :deep(.el-radio__input.is-checked .el-radio__inner::after) {
background: #ffffff;
}
.new-product-value-page :deep(.el-radio.is-checked) {
border-color: var(--student-accent);
color: var(--student-accent);
}
.new-product-value-page :deep(.el-radio.is-checked .el-radio__label) {
color: var(--student-accent);
}
.custom-scene-input {
margin-top: 10px;
}
.product-name-field {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: var(--student-text);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
.image-upload-trigger {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
padding: 0 16px;
border: 1px dashed #9ca3af;
border-radius: 8px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.18s ease;
input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
&:hover:not(.disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.product-image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 10px;
margin-top: 12px;
}
.product-image-card {
display: grid;
gap: 6px;
min-width: 0;
margin: 0;
padding: 6px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #ffffff;
figcaption {
overflow: hidden;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 2px;
}
}
.product-image-preview {
aspect-ratio: 1.2;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 6px;
background: #f9fafb;
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: var(--student-danger);
background: transparent;
font: inherit;
font-size: var(--student-fs-caption);
font-weight: 600;
cursor: pointer;
transition: color 0.16s ease;
&:hover {
color: #b0443d;
text-decoration: underline;
}
}
.product-image-full {
display: block;
width: 100%;
max-height: 72vh;
object-fit: contain;
}
/* ============================================================
* 数据表格:卡片行式 + 序号徽章
* ============================================================ */
.training-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.training-data-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
th,
td {
border: none;
border-bottom: 1px solid #f0f1f3;
padding: 10px 14px;
vertical-align: top;
}
th {
color: var(--student-text-secondary);
background: #f9fafb;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: left;
letter-spacing: 0.02em;
white-space: nowrap;
}
td {
color: #374151;
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
tbody tr {
transition: background 0.16s ease;
&:hover {
background: #f9fafb;
}
&:last-child td {
border-bottom: none;
}
}
}
.cell-input {
min-height: 36px;
padding: 0 10px;
}
.cell-textarea {
min-height: 110px;
padding: 8px 10px;
resize: vertical;
line-height: 1.6;
}
.dimension-cell {
min-width: 160px;
color: var(--student-text);
text-align: left;
strong,
span {
display: block;
}
strong {
margin-bottom: 6px;
font-size: var(--student-fs-body);
font-weight: 700;
}
span {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 500;
line-height: 1.5;
}
}
.requirement-cell,
.example-cell {
min-width: 160px;
color: #6b7280;
line-height: 1.65;
}
.example-cell {
color: #92400e;
}
.reference-points {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.analysis-input-cell {
min-width: 260px;
}
.analysis-count {
margin: 6px 2px 0;
color: var(--student-danger);
font-size: var(--student-fs-caption);
font-weight: 600;
&.is-ready {
color: var(--student-success);
}
}
/* ============================================================
* 底部导航:工具栏式
* ============================================================ */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
/* ============================================================
* Step 01: 三层次分类表格
* ============================================================ */
.classification-table-wrap {
margin-top: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
overflow: hidden;
}
.classification-table {
th,
td {
border: none;
border-bottom: 1px solid #f0f1f3;
}
th {
padding: 10px 14px;
color: var(--student-text-secondary);
background: #f9fafb;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: left;
white-space: nowrap;
}
td {
padding: 8px 14px;
color: #374151;
background: #ffffff;
font-size: var(--student-fs-body-sm);
}
tbody tr {
transition: background 0.16s ease;
&:hover {
background: #f9fafb;
}
&:last-child td {
border-bottom: none;
}
}
th:first-child,
td:first-child {
width: 56px;
text-align: center;
}
th:last-child,
td:last-child {
width: 220px;
}
}
.layer-legend {
display: inline-flex;
gap: 3px;
margin-left: 6px;
vertical-align: 2px;
i {
width: 10px;
height: 3px;
border-radius: 2px;
background: var(--dot);
}
}
.classification-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
border-radius: 6px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
}
.classification-selling-point {
color: #1f2937;
font-weight: 500;
line-height: 1.6;
}
.classification-select-cell :deep(.el-select) {
width: 100%;
}
.classification-select-cell {
:deep(.el-select__selected-item),
:deep(.el-select__placeholder),
:deep(.el-input__inner) {
color: var(--student-text) !important;
-webkit-text-fill-color: var(--student-text) !important;
font-weight: 600;
}
:deep(.el-select.is-disabled .el-select__selected-item),
:deep(.el-input.is-disabled .el-input__inner) {
color: #565e6b !important;
-webkit-text-fill-color: #565e6b !important;
}
:deep(.el-select__caret) {
color: var(--student-text-secondary);
}
}
.classification-table tbody td {
background: #ffffff !important;
box-shadow: none !important;
}
.classification-feedback,
.classification-summary {
margin: 6px 0 0;
color: var(--student-danger);
font-size: var(--student-fs-caption);
line-height: 1.5;
font-weight: 600;
}
.classification-row--correct .classification-feedback,
.classification-summary.is-success {
color: var(--student-success);
}
.classification-summary {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-radius: 8px;
color: var(--student-danger);
background: rgba(239, 68, 68, 0.08);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
.classification-summary.is-success {
background: rgba(43, 164, 113, 0.1);
}
/* ============================================================
* 三层次彩色标识条
* ============================================================ */
.dimension-cell::before {
content: "";
display: block;
width: 24px;
height: 3px;
margin-bottom: 6px;
border-radius: 2px;
background: var(--student-accent);
}
.layer-table tr:nth-child(1) .dimension-cell::before {
background: var(--student-layer-1);
}
.layer-table tr:nth-child(2) .dimension-cell::before {
background: var(--student-layer-2);
}
.layer-table tr:nth-child(3) .dimension-cell::before {
background: var(--student-layer-3);
}
/* ============================================================
* Step 04: 三层次字段卡
* ============================================================ */
.field-block {
display: grid;
gap: 8px;
margin-bottom: 10px;
color: #374151;
font-size: var(--student-fs-body);
font-weight: 600;
>span {
line-height: 1.6;
}
}
.field-block.is-layer {
position: relative;
margin-bottom: 12px;
padding: 14px 16px 14px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--bar, var(--student-accent));
}
/* 第 4 步序号徽章:复刻参考稿——实底蓝正方形 + 白字前导零 */
.step4-no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-right: 10px;
border-radius: 6px;
color: #ffffff;
background: #3b82f6;
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 600;
line-height: 1;
vertical-align: middle;
}
}
.field-block.layer-core {
--bar: var(--student-layer-1);
}
.field-block.layer-tangible {
--bar: var(--student-layer-2);
}
.field-block.layer-extended {
--bar: var(--student-layer-3);
}
/* ============================================================
* 操作工具栏横条:内联布局,去掉悬浮和边框
* ============================================================ */
.task-action-strip {
margin: 0;
padding: 10px 16px;
// 让 TrainingTaskToolbar 内部按钮在横条内右对齐
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* ============================================================
* 响应式
* ============================================================ */
@media (max-width: 1100px) {
.step-nav-right {
margin-left: 0;
}
}
@media (max-width: 900px) {
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.btn-nav,
.btn-primary,
.btn-ghost,
.btn-save {
width: 100%;
}
.classification-table {
min-width: 720px;
}
:deep(.training-task-toolbar) {
justify-content: stretch;
}
:deep(.training-task-toolbar > *) {
flex: 1 1 auto;
}
}
@media (max-width: 720px) {
.step-two-survey {
.survey-options {
grid-template-columns: 1fr;
}
}
}
/* ===== 步骤内小节标题(蓝底序号,统一规范) ===== */
.section-heading {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
>span {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent, #2563eb);
font-size: var(--student-fs-body, 14px);
font-weight: 700;
}
h3 {
margin: 0;
color: var(--student-text, #1f2937);
font-size: var(--student-fs-h1, 18px);
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--student-text-secondary, #6b7280);
font-size: var(--student-fs-body, 14px);
line-height: 1.6;
}
}
/* ============================================================
* 共享组件 TrainingStepOneChoiceQuestions 浅色化
* 组件本体为深色且禁止修改,此处用拍平的 :deep() 选择器覆盖
* 选择器必须保留完整类链,确保特异性压过组件 scoped 规则
* ============================================================ */
.new-product-value-page :deep(.training-step-one-questions) {
margin: 16px 0;
padding: 16px 18px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
}
.new-product-value-page :deep(.training-step-one-questions header) {
align-items: center;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f1f3;
}
.new-product-value-page :deep(.training-step-one-questions header span) {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 6px;
background: var(--student-accent-soft);
color: var(--student-accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.05em;
}
.new-product-value-page :deep(.training-step-one-questions header p) {
margin: 0;
color: var(--student-text-secondary);
font-size: 13px;
}
.new-product-value-page :deep(.training-step-one-question) {
padding: 14px 0 2px;
}
.new-product-value-page :deep(.training-step-one-question + .training-step-one-question) {
border-top: 1px dashed #e5e7eb;
}
.new-product-value-page :deep(.training-step-one-question h4) {
margin: 0 0 10px;
color: var(--student-text);
font-size: 15px;
line-height: 1.6;
}
.new-product-value-page :deep(.training-step-one-question h4 b) {
color: var(--student-accent);
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio-group) {
display: grid;
gap: 8px;
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio.is-bordered) {
width: 100%;
height: auto;
min-height: 38px;
margin: 0;
padding: 9px 12px;
color: var(--student-text);
line-height: 1.5;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio.is-bordered:hover) {
border-color: var(--student-accent-light);
background: var(--student-accent-soft);
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio.is-bordered.is-checked) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio__label) {
color: var(--student-text);
white-space: normal;
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio__input.is-checked + .el-radio__label) {
color: var(--student-text);
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question .el-radio__input.is-checked .el-radio__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
.new-product-value-page :deep(.training-step-one-questions .training-step-one-question strong) {
color: var(--student-accent);
}
</style>
<!--
非 scoped 全局微调(教育蓝主题,仅作用于本页):
1) app-main 画布改为纯白(scoped 无法触及祖先节点);
2) 步骤导航激活态教育蓝、完成态语义绿;
3) 顶部工具栏主按钮教育蓝实底。
所有选择器均以 .new-product-value-page 为根,不影响其他页面。
-->
<style lang="scss">
/* ============================================================
* 步骤导航:胶囊分段 + 连接线(教育蓝主题)
* 覆盖全局 !important 规则
* ============================================================ */
#app .studentLayout section.app-main:has(.new-product-value-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-tab {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-bar {
display: none !important;
}
.step-no {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: var(--student-fs-body-sm) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no {
background: #2ba471 !important;
color: #ffffff !important;
}
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-no {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.new-product-value-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.new-product-value-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 顶部工具栏按钮:教育蓝统一
* 覆盖 TrainingTaskToolbar.vue 和 TrainingMaterialButton.vue 的 scoped 样式
* ============================================================ */
.new-product-value-page .training-task-toolbar {
gap: 8px !important;
}
/* TaskToolbar 自己的按钮(提交/重新实训/导出) */
.new-product-value-page .task-toolbar-button.el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 12px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: all 0.16s ease !important;
}
.new-product-value-page .task-toolbar-button.el-button .el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !important;
}
.new-product-value-page .task-toolbar-button.el-button:hover,
.new-product-value-page .task-toolbar-button.el-button:focus {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
.new-product-value-page .task-toolbar-button--submit.el-button {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15) !important;
}
.new-product-value-page .task-toolbar-button--submit.el-button:hover,
.new-product-value-page .task-toolbar-button--submit.el-button:focus {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22) !important;
}
.new-product-value-page .task-toolbar-button.el-button.is-loading {
border-color: rgba(37, 99, 235, 0.35) !important;
color: #2563eb !important;
background: #ffffff !important;
}
/* TrainingMaterialButton 组件(案例资料/实训数据/指导手册等) */
.new-product-value-page .training-task-toolbar .material-button.el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 12px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: all 0.16s ease !important;
}
.new-product-value-page .training-task-toolbar .material-button.el-button .el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !important;
}
.new-product-value-page .training-task-toolbar .material-button.el-button:hover,
.new-product-value-page .training-task-toolbar .material-button.el-button:focus {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
.new-product-value-page .training-task-toolbar .material-button.el-button.is-plain,
.new-product-value-page .training-task-toolbar .material-button.el-button.is-plain:hover,
.new-product-value-page .training-task-toolbar .material-button.el-button.is-plain:focus {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
transform: none !important;
box-shadow: none !important;
}
</style>