|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="structure-page student-training-shell">
|
|
|
|
|
|
<main class="training-core">
|
|
|
|
|
|
<section class="task-header">
|
|
|
|
|
|
<div class="task-badge">
|
|
|
|
|
|
<el-icon><Operation /></el-icon>
|
|
|
|
|
|
产品规划与设计
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h1 class="task-title">{{ taskTitle }}</h1>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
|
|
|
|
|
|
|
|
|
|
|
|
<section class="task-card">
|
|
|
|
|
|
<div class="workbench-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Training Task</p>
|
|
|
|
|
|
<h3>任务实训</h3>
|
|
|
|
|
|
<p v-if="taskRequirement" class="workbench-description">{{ taskRequirement }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="workbench-actions">
|
|
|
|
|
|
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
|
|
|
|
|
|
<button type="button" class="outline-action" @click="exportOutcome">
|
|
|
|
|
|
<el-icon><Download /></el-icon>
|
|
|
|
|
|
导出实训成果
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<nav v-if="configuredSteps.length" class="step-indicator" aria-label="产品架构设计步骤">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="(step, index) in configuredSteps"
|
|
|
|
|
|
:key="`${step}-${index}`"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="step-tab"
|
|
|
|
|
|
:class="{ active: activeStep === index + 1 }"
|
|
|
|
|
|
:aria-current="activeStep === index + 1 ? 'step' : undefined"
|
|
|
|
|
|
@click="setCurrentStep(index + 1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ step }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(1)" class="step-card">
|
|
|
|
|
|
<div v-if="activeStepName" class="step-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p>
|
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="step-content knowledge-content">
|
|
|
|
|
|
<h4>产品组合策略</h4>
|
|
|
|
|
|
<p>产品组合的四个要素:宽度(企业经营的产品线数量)、长度(企业经营的产品项目总数)、深度(每条产品线中产品项目的多少)和一致性(各产品线在最终用途、生产条件、销售渠道或其他方面的关联程度)。</p>
|
|
|
|
|
|
<div class="info-grid">
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<h5>扩大产品组合</h5>
|
|
|
|
|
|
<p>开拓产品组合的广度和加强产品组合的深度,满足不同消费者需求,提高市场占有率,充分利用企业资源并分散市场风险。</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<h5>缩减产品组合</h5>
|
|
|
|
|
|
<p>削减产品线或产品项目,集中力量经营获利大的产品线和产品项目,提高利润率。</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<h5>产品线现代化</h5>
|
|
|
|
|
|
<p>在高技术时代持续更新产品,保持竞争力。</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h4>商品结构规划</h4>
|
|
|
|
|
|
<p>商品结构可按预测属性划分为形象类、畅销类、主推类,也可按商品类别和规格划分。明确商品矩阵后,再结合包围型、差异型、互补型等价格带策略完成布局。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(2)" class="step-card">
|
|
|
|
|
|
<div v-if="activeStepName" class="step-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p>
|
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="step-content">
|
|
|
|
|
|
<p class="step-desc">以淘宝电商女装为例,选择一个产品结构规划合理的优质店铺,分析其产品组合策略和商品结构。</p>
|
|
|
|
|
|
<label class="field-block">
|
|
|
|
|
|
<span>您选择的淘宝店铺网址:</span>
|
|
|
|
|
|
<el-input v-model="commodityComposition.taobaoUrl" placeholder="请输入店铺网址" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="field-block">
|
|
|
|
|
|
<span>该店铺目前有哪些产品分类:</span>
|
|
|
|
|
|
<div class="category-grid">
|
|
|
|
|
|
<el-input v-for="item in productCategory" :key="item.id" v-model="item.value" placeholder="请输入分类" />
|
|
|
|
|
|
<button type="button" class="mini-add" @click="addCategory"><el-icon><Plus /></el-icon> 添加分类</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label class="field-block">
|
|
|
|
|
|
<span>请阐述您选择的淘宝店铺产品组合的合理性:</span>
|
|
|
|
|
|
<el-input v-model="commodityComposition.rationality" type="textarea" :autosize="{ minRows: 7, maxRows: 10 }" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field-block">
|
|
|
|
|
|
<span>请分析该店铺的销售数据,给出产品组合优化建议:</span>
|
|
|
|
|
|
<el-input v-model="commodityComposition.optimizationSuggestions" type="textarea" :autosize="{ minRows: 7, maxRows: 10 }" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(3)" class="step-card">
|
|
|
|
|
|
<div v-if="activeStepName" class="step-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p>
|
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="step-content">
|
|
|
|
|
|
<p class="step-desc">判断店铺商品结构,并明确商品矩阵中不同角色产品;可为每项补充商品图片作为分析依据。</p>
|
|
|
|
|
|
<div class="role-grid">
|
|
|
|
|
|
<article class="role-card">
|
|
|
|
|
|
<label class="field-block"><span>形象类产品:</span><el-input v-model="commodityComposition.imageProduct" /></label>
|
|
|
|
|
|
<ProductStructureImageUpload
|
|
|
|
|
|
:product="popularProducts[0]"
|
|
|
|
|
|
:upload-action="uploadAction"
|
|
|
|
|
|
:upload-data="uploadData"
|
|
|
|
|
|
@success="handleUploadSuccess($event, 0)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article class="role-card role-card--wide">
|
|
|
|
|
|
<span class="field-label">畅销类产品(销量前 3 的产品):</span>
|
|
|
|
|
|
<div class="inline-fields">
|
|
|
|
|
|
<el-input v-model="commodityComposition.popularProduct1" />
|
|
|
|
|
|
<el-input v-model="commodityComposition.popularProduct2" />
|
|
|
|
|
|
<el-input v-model="commodityComposition.popularProduct3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ProductStructureImageUpload
|
|
|
|
|
|
:product="popularProducts[1]"
|
|
|
|
|
|
:upload-action="uploadAction"
|
|
|
|
|
|
:upload-data="uploadData"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
@success="handleUploadSuccess($event, 1, true)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article class="role-card">
|
|
|
|
|
|
<label class="field-block"><span>主推类产品(产品项目最多的品类):</span><el-input v-model="commodityComposition.mainProduct" /></label>
|
|
|
|
|
|
<ProductStructureImageUpload
|
|
|
|
|
|
:product="popularProducts[2]"
|
|
|
|
|
|
:upload-action="uploadAction"
|
|
|
|
|
|
:upload-data="uploadData"
|
|
|
|
|
|
@success="handleUploadSuccess($event, 2)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p class="field-label matrix-title">商品矩阵:</p>
|
|
|
|
|
|
<div class="role-grid">
|
|
|
|
|
|
<article v-for="item in matrixFields" :key="item.key" class="role-card">
|
|
|
|
|
|
<label class="field-block"><span>{{ item.label }}</span><el-input v-model="commodityComposition[item.key]" /></label>
|
|
|
|
|
|
<ProductStructureImageUpload
|
|
|
|
|
|
:product="popularProducts[item.index]"
|
|
|
|
|
|
:upload-action="uploadAction"
|
|
|
|
|
|
:upload-data="uploadData"
|
|
|
|
|
|
@success="handleUploadSuccess($event, item.index)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(4)" class="step-card">
|
|
|
|
|
|
<div v-if="activeStepName" class="step-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p>
|
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="step-content">
|
|
|
|
|
|
<label class="field-block">
|
|
|
|
|
|
<span>请根据季节及市场需求分析该店铺商品结构是否合理,并给出优化建议:</span>
|
|
|
|
|
|
<el-input v-model="commodityComposition.context" type="textarea" :autosize="{ minRows: 11, maxRows: 15 }" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-show="hasConfiguredSteps && activeStep > 4" class="step-card completion-card">
|
|
|
|
|
|
<div class="step-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p>
|
|
|
|
|
|
<h3>{{ activeStepName }}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="step-desc">请检查店铺信息、产品分类、商品结构和优化建议是否填写完整,再保存或提交实训成果。</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="hasConfiguredSteps" class="step-navigation">
|
|
|
|
|
|
<button type="button" class="btn-nav" :disabled="activeStep === 1" @click="goPreviousStep"><el-icon><ArrowLeft /></el-icon>上一步</button>
|
|
|
|
|
|
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button>
|
|
|
|
|
|
<button type="button" class="btn-nav btn-primary" :disabled="activeStep === configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="training-actions">
|
|
|
|
|
|
<el-button class="training-action training-action--submit" :loading="saving" @click="submitTask">提交任务</el-button>
|
|
|
|
|
|
<el-button class="training-action training-action--reset" :loading="saving" @click="resetTask">清空填写</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<TrainingAiSidebar
|
|
|
|
|
|
study-tip="当前任务建议:先梳理产品组合,再建立商品矩阵,最后结合季节和市场需求形成优化建议。"
|
|
|
|
|
|
reference-title="产品结构规划参考"
|
|
|
|
|
|
reference-text="· 产品组合:宽度、长度、深度、一致性<br />· 商品结构:形象类、畅销类、主推类<br />· 商品矩阵:引流款、日常款、活动款、利润款、形象款"
|
|
|
|
|
|
:progress-items="progressItems"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
|
|
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, Download, Operation, Plus } from "@element-plus/icons-vue";
|
|
|
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
|
|
|
|
|
|
import ProductStructureImageUpload from "@/views/product/components/ProductStructureImageUpload.vue";
|
|
|
|
|
|
import useUserStore from "@/store/modules/user";
|
|
|
|
|
|
import * as analysisAPI from "@/api/market-demand-mining.js";
|
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
|
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
|
import { parseTrainingArray } from "@/views/training/taskKeyMap";
|
|
|
|
|
|
|
|
|
|
|
|
const TASK_KEY = "product-structure";
|
|
|
|
|
|
const { proxy } = getCurrentInstance();
|
|
|
|
|
|
const userStore = useUserStore();
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const taskConfig = ref(null);
|
|
|
|
|
|
const activeStep = ref(1);
|
|
|
|
|
|
const apiBase = (import.meta.env.VITE_APP_BASE_API || "/").replace(/\/?$/, "/");
|
|
|
|
|
|
const uploadAction = `${apiBase}api/cpgh/upload`;
|
|
|
|
|
|
const uploadData = computed(() => ({ userId: userStore.userInfo.userId, module: "产品结构规划" }));
|
|
|
|
|
|
|
|
|
|
|
|
const defaultRequirement = "按任务步骤完成店铺产品组合分析、商品结构判断和优化建议,提交内容需要结构清晰、依据充分。";
|
|
|
|
|
|
const defaultBackground = "以淘宝电商女装店铺为例,分析店铺产品组合与商品结构,并形成可执行的产品结构规划建议。";
|
|
|
|
|
|
const defaultGoals = ["理解产品组合策略与商品结构规划方法。", "能够分析店铺商品结构并建立商品矩阵。", "形成有依据的产品组合优化建议。"];
|
|
|
|
|
|
const taskTitle = computed(() => taskConfig.value?.taskName || "产品架构设计");
|
|
|
|
|
|
const taskBackground = computed(() => taskConfig.value?.background || defaultBackground);
|
|
|
|
|
|
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement);
|
|
|
|
|
|
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, defaultGoals));
|
|
|
|
|
|
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps));
|
|
|
|
|
|
const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0);
|
|
|
|
|
|
const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || "");
|
|
|
|
|
|
const progressItems = computed(() => configuredSteps.value.map((step, index) => ({ text: step, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
|
|
|
|
|
|
|
|
|
|
|
|
const commodityComposition = ref(createCommodityComposition());
|
|
|
|
|
|
const productCategory = ref(createCategories());
|
|
|
|
|
|
const popularProducts = ref(createPopularProducts());
|
|
|
|
|
|
const matrixFields = [
|
|
|
|
|
|
{ key: "trafficProduct", label: "引流款(性价比最高的低价值单品):", uploadLabel: "引流款", index: 3 },
|
|
|
|
|
|
{ key: "dailyProduct", label: "日常款:", uploadLabel: "日常款", index: 4 },
|
|
|
|
|
|
{ key: "eventProduct", label: "活动款(买送等促销活动):", uploadLabel: "活动款", index: 5 },
|
|
|
|
|
|
{ key: "profitProduct", label: "利润款:", uploadLabel: "利润款", index: 6 },
|
|
|
|
|
|
{ key: "flagshipProduct", label: "形象款(店铺主图):", uploadLabel: "形象款", index: 7 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function createCommodityComposition() {
|
|
|
|
|
|
return { context: "", dailyProduct: "", eventProduct: "", flagshipProduct: "", imageProduct: "", mainProduct: "", optimizationSuggestions: "", popularProduct1: "", popularProduct2: "", popularProduct3: "", profitProduct: "", rationality: "", taobaoUrl: "", trafficProduct: "" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createCategories() {
|
|
|
|
|
|
return Array.from({ length: 4 }, (_, index) => ({ id: index + 1, value: "" }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createPopularProducts() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ name: "形象类", image: "", key: "imgOne" }, { name: "畅销类", image: [], key: "imgTwo" }, { name: "主推类", image: "", key: "imgThree" }, { name: "引流款", image: "", key: "imgFour" },
|
|
|
|
|
|
{ name: "日常款", image: "", key: "imgFive" }, { name: "活动款", image: "", key: "imgSix" }, { name: "利润款", image: "", key: "imgSeven" }, { name: "形象款", image: "", key: "imgEight" },
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await loadTaskConfig();
|
|
|
|
|
|
await getTaskInfo();
|
|
|
|
|
|
await loadSavedProgress();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function loadTaskConfig() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getTrainingTaskByKey(TASK_KEY);
|
|
|
|
|
|
taskConfig.value = res?.data || null;
|
|
|
|
|
|
activeStep.value = normalizeActiveStep(activeStep.value);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
taskConfig.value = null;
|
|
|
|
|
|
activeStep.value = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeActiveStep(value) {
|
|
|
|
|
|
if (!configuredSteps.value.length) return 1;
|
|
|
|
|
|
const step = Number(value) || 1;
|
|
|
|
|
|
return Math.min(Math.max(step, 1), configuredSteps.value.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setCurrentStep(step) { activeStep.value = normalizeActiveStep(step); }
|
|
|
|
|
|
function isSectionVisible(section) { return !hasConfiguredSteps.value || activeStep.value === section; }
|
|
|
|
|
|
function goPreviousStep() { setCurrentStep(activeStep.value - 1); }
|
|
|
|
|
|
function goNextStep() { setCurrentStep(activeStep.value + 1); }
|
|
|
|
|
|
function addCategory() { productCategory.value.push({ id: productCategory.value.length + 1, value: "" }); }
|
|
|
|
|
|
|
|
|
|
|
|
function handleUploadSuccess(response, index, multiple = false) {
|
|
|
|
|
|
const product = popularProducts.value[index];
|
|
|
|
|
|
const url = response?.data;
|
|
|
|
|
|
if (!product || !url) return;
|
|
|
|
|
|
product.image = multiple
|
|
|
|
|
|
? [...(Array.isArray(product.image) ? product.image : []), url]
|
|
|
|
|
|
: url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildProgress() {
|
|
|
|
|
|
return { activeStep: activeStep.value, commodityComposition: commodityComposition.value, productCategory: productCategory.value, popularProducts: popularProducts.value, updatedAt: new Date().toISOString() };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyProgress(value) {
|
|
|
|
|
|
if (!value || typeof value !== "object") return;
|
|
|
|
|
|
activeStep.value = normalizeActiveStep(value.activeStep);
|
|
|
|
|
|
commodityComposition.value = { ...createCommodityComposition(), ...(value.commodityComposition || {}) };
|
|
|
|
|
|
productCategory.value = Array.isArray(value.productCategory) && value.productCategory.length ? value.productCategory : createCategories();
|
|
|
|
|
|
popularProducts.value = createPopularProducts().map((item, index) => ({ ...item, ...(Array.isArray(value.popularProducts) ? value.popularProducts[index] || {} : {}) }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSavedProgress() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getStudentTrainingAnswer(TASK_KEY);
|
|
|
|
|
|
const raw = res?.data?.step1Answer;
|
|
|
|
|
|
if (raw) applyProgress(JSON.parse(raw));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// The legacy echo API remains the fallback for historical answers.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function persistProgress(saveAction = "SAVE") {
|
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()) });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveCurrentProgress() {
|
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
try { await persistProgress("SAVE"); proxy?.$modal?.msgSuccess("保存成功"); } finally { saving.value = false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isFormComplete(data) {
|
|
|
|
|
|
return !Object.keys(data).filter((key) => key !== "context").some((key) => !String(data[key] || "").trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitTask() {
|
|
|
|
|
|
if (!isFormComplete(commodityComposition.value) || productCategory.value.some((item) => !String(item.value || "").trim())) {
|
|
|
|
|
|
proxy?.$modal?.msgWarning("请先完成实验实训!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = { ...commodityComposition.value, productCategories: JSON.stringify(productCategory.value), userId: userStore.userInfo.userId };
|
|
|
|
|
|
popularProducts.value.forEach((item) => { params[item.key] = JSON.stringify(item.image); });
|
|
|
|
|
|
await analysisAPI.productStructurePlanningSubmit(params);
|
|
|
|
|
|
await persistProgress("SUBMIT");
|
|
|
|
|
|
proxy?.$modal?.msgSuccess("提交成功");
|
|
|
|
|
|
} finally { saving.value = false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getTaskInfo() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await analysisAPI.productStructurePlanningEcho({ userId: userStore.userInfo.userId });
|
|
|
|
|
|
if (!res?.data) return;
|
|
|
|
|
|
commodityComposition.value = { ...createCommodityComposition(), ...res.data };
|
|
|
|
|
|
try { productCategory.value = JSON.parse(res.data.productCategories) || createCategories(); } catch (error) { productCategory.value = createCategories(); }
|
|
|
|
|
|
popularProducts.value.forEach((item) => {
|
|
|
|
|
|
try { item.image = JSON.parse(res.data[item.key]); } catch (error) { item.image = item.name === "畅销类" ? [] : ""; }
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Historical data is optional; student progress is loaded from the common training-answer API.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resetTask() {
|
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await analysisAPI.productPricingReTrain({ userId: userStore.userInfo.userId, module: "产品结构规划" });
|
|
|
|
|
|
commodityComposition.value = createCommodityComposition();
|
|
|
|
|
|
productCategory.value = createCategories();
|
|
|
|
|
|
popularProducts.value = createPopularProducts();
|
|
|
|
|
|
activeStep.value = 1;
|
|
|
|
|
|
await persistProgress("RESET");
|
|
|
|
|
|
proxy?.$modal?.msgSuccess("清空填写成功");
|
|
|
|
|
|
} finally { saving.value = false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exportOutcome() {
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(buildProgress(), null, 2)], { type: "application/json;charset=utf-8" });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = url;
|
|
|
|
|
|
link.download = `${taskTitle.value}-实训成果.json`;
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.structure-page { display: grid; grid-template-columns: minmax(720px, 1fr) 340px; gap: 24px; min-height: calc(100vh - 76px); padding: 24px 24px 32px; color: #eef5ff; background: #06111d; }
|
|
|
|
|
|
.training-core { min-width: 0; padding: 28px; border: 1px solid rgba(0, 180, 255, 0.25); border-radius: 36px; background: rgba(8, 18, 28, .6); box-shadow: inset 0 1px 0 rgba(169, 229, 255, .08), 0 22px 48px rgba(0, 0, 0, .22); }
|
|
|
|
|
|
.task-header { margin-bottom: 24px; }
|
|
|
|
|
|
.task-badge { display: inline-flex; align-items: center; gap: 8px; min-height: 28px; padding: 4px 14px; border: 1px solid rgba(99, 217, 255, .55); border-radius: 999px; color: #dff5ff; background: rgba(17, 126, 178, .36); font-size: 12px; font-weight: 700; line-height: 1.4; }
|
|
|
|
|
|
.task-title { margin: 12px 0 0; color: #fff; font-size: 30px; font-weight: 900; line-height: 1.3; }
|
|
|
|
|
|
.task-card, .step-card { border: 1px solid rgba(82, 174, 226, .42); border-radius: 26px; background: rgba(0, 25, 42, .64); }
|
|
|
|
|
|
.task-card { margin-top: 28px; padding: 18px 22px 22px; }
|
|
|
|
|
|
.workbench-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 1px solid rgba(0, 180, 255, .2); }
|
|
|
|
|
|
.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; letter-spacing: .02em; }
|
|
|
|
|
|
.workbench-head h3 { margin: 4px 0 8px; color: #fff; font-size: 26px; font-weight: 800; line-height: 1.3; }
|
|
|
|
|
|
.workbench-description, .step-desc { margin: 0; color: #d7ecff; font-size: 15px; line-height: 1.8; }
|
|
|
|
|
|
.workbench-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
|
|
|
|
|
|
.outline-action, .btn-nav, .mini-add { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 18px; border: 1px solid rgba(99, 217, 255, .55); border-radius: 999px; color: #e9fbff; background: rgba(17, 126, 178, .36); font: inherit; font-size: 14px; font-weight: 800; cursor: pointer; }
|
|
|
|
|
|
.outline-action:hover, .outline-action:focus-visible, .btn-nav:hover:not(:disabled), .btn-nav:focus-visible, .mini-add:hover, .mini-add:focus-visible { border-color: rgba(116, 235, 255, .85); background: rgba(18, 146, 204, .5); outline: none; transform: translateY(-1px); }
|
|
|
|
|
|
.step-indicator { display: flex; gap: 6px; min-height: 58px; margin: 0 0 32px; padding: 6px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, .3); border-radius: 999px; background: rgba(0, 20, 35, .65); }
|
|
|
|
|
|
.step-tab { flex: 1 0 max-content; min-height: 46px; padding: 0 18px; border: 1px solid transparent; border-radius: 999px; color: #9ab3d0; background: transparent; font: inherit; font-size: 14px; font-weight: 800; cursor: pointer; }
|
|
|
|
|
|
.step-tab:hover, .step-tab:focus-visible { border-color: rgba(99, 217, 255, .55); color: #e9fbff; outline: none; }
|
|
|
|
|
|
.step-tab.active { border-color: rgba(92, 224, 255, .7); color: #fff; background: linear-gradient(95deg, #0a6b9e, #0a5c86); }
|
|
|
|
|
|
.step-card { margin-top: 32px; padding: 18px 22px 22px; }
|
|
|
|
|
|
.step-indicator + .step-card, .workbench-head + .step-card { margin-top: 0; }
|
|
|
|
|
|
.step-card + .step-card { margin-top: 16px; }
|
|
|
|
|
|
.step-title-row { margin-bottom: 14px; }
|
|
|
|
|
|
.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }
|
|
|
|
|
|
.step-content { color: #d7ecff; }
|
|
|
|
|
|
.knowledge-content h4 { margin: 0 0 12px; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }
|
|
|
|
|
|
.knowledge-content h4:not(:first-child) { margin-top: 24px; }
|
|
|
|
|
|
.knowledge-content p { margin: 0; color: #d7ecff; font-size: 15px; line-height: 1.8; }
|
|
|
|
|
|
.info-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
|
|
|
|
|
.info-grid article, .role-card { padding: 16px; border: 1px solid rgba(0, 180, 255, .22); border-radius: 20px; background: rgba(1, 14, 26, .72); }
|
|
|
|
|
|
.info-grid h5 { margin: 0 0 8px; color: #dff5ff; font-size: 15px; font-weight: 800; }
|
|
|
|
|
|
.info-grid p { font-size: 14px; line-height: 1.72; }
|
|
|
|
|
|
.field-block { display: block; margin: 16px 0 0; }
|
|
|
|
|
|
.field-block span, .field-label { display: block; margin-bottom: 8px; color: #dff5ff; font-size: 14px; font-weight: 800; line-height: 1.6; }
|
|
|
|
|
|
.field-block :deep(.el-input__wrapper), .field-block :deep(.el-textarea__inner), .category-grid :deep(.el-input__wrapper), .inline-fields :deep(.el-input__wrapper) { border: 1px solid rgba(45, 95, 126, .95); border-radius: 14px; box-shadow: none; background: #0f1a24; }
|
|
|
|
|
|
.field-block :deep(.el-input__wrapper), .category-grid :deep(.el-input__wrapper), .inline-fields :deep(.el-input__wrapper) { min-height: 42px; padding: 0 14px; }
|
|
|
|
|
|
.field-block :deep(.el-input__inner), .field-block :deep(.el-textarea__inner), .category-grid :deep(.el-input__inner), .inline-fields :deep(.el-input__inner) { color: #eef5ff; font-size: 14px; }
|
|
|
|
|
|
.field-block :deep(.el-textarea__inner) { min-height: 110px !important; padding: 12px 14px; line-height: 1.65; resize: vertical; }
|
|
|
|
|
|
.field-block :deep(.el-input__wrapper.is-focus), .field-block :deep(.el-textarea__inner:focus), .category-grid :deep(.el-input__wrapper.is-focus), .inline-fields :deep(.el-input__wrapper.is-focus) { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }
|
|
|
|
|
|
.category-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
|
|
|
|
|
.mini-add { justify-self: start; }
|
|
|
|
|
|
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
|
|
|
|
|
.role-card--wide { grid-column: span 2; }
|
|
|
|
|
|
.role-card .field-block { margin: 0; }
|
|
|
|
|
|
.inline-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
|
|
|
|
|
.matrix-title { margin: 24px 0 0; }
|
|
|
|
|
|
.upload-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
|
|
|
|
|
|
.upload-row :deep(.upload-action) { min-height: 40px; padding: 0 18px; border-radius: 999px; font-size: 14px; font-weight: 800; }
|
|
|
|
|
|
.preview-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
|
|
|
|
.preview-list :deep(.el-image) { width: 50px; height: 50px; overflow: hidden; border: 1px solid rgba(0, 180, 255, .22); border-radius: 10px; background: #0f1a24; }
|
|
|
|
|
|
.step-navigation { display: flex; justify-content: center; gap: 14px; margin-top: 22px; }
|
|
|
|
|
|
.btn-nav.btn-save, .btn-nav.btn-primary { border-color: rgba(92, 224, 255, .7); color: #fff; background: linear-gradient(95deg, #0b84bd, #096491); }
|
|
|
|
|
|
.btn-nav:disabled { cursor: not-allowed; opacity: .45; transform: none; }
|
|
|
|
|
|
.training-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 52px; margin-top: 36px; }
|
|
|
|
|
|
.training-actions :deep(.training-action) { min-width: 172px; height: 64px; padding: 0 28px; border: 1px solid #2c83aa; border-radius: 6px; color: #dff7ff; background: rgba(13, 64, 88, .62); font-size: 24px; font-weight: 900; }
|
|
|
|
|
|
.training-actions :deep(.training-action:hover:not(.is-disabled)) { border-color: #46b5e7; color: #fff; background: rgba(18, 85, 116, .78); }
|
|
|
|
|
|
@media (max-width: 1100px) { .structure-page { grid-template-columns: 1fr; } }
|
|
|
|
|
|
@media (max-width: 900px) { .structure-page { padding: 14px; } .training-core { padding: 18px; border-radius: 24px; } .workbench-head, .workbench-actions, .step-navigation { flex-direction: column; align-items: stretch; } .workbench-actions, .outline-action, .btn-nav, .training-actions :deep(.training-action) { width: 100%; } .step-indicator { flex-direction: column; border-radius: 26px; } .step-tab { flex: none; } .info-grid, .category-grid, .role-grid, .inline-fields { grid-template-columns: 1fr; } .role-card--wide { grid-column: span 1; } }
|
|
|
|
|
|
</style>
|