|
|
|
|
|
<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 workbench-head--actions-only">
|
|
|
|
|
|
<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="产品架构设计步骤">
|
|
|
|
|
|
<template v-for="(step, index) in configuredSteps" :key="`${step}-${index}`">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]"
|
|
|
|
|
|
:aria-current="activeStep === index + 1 ? 'step' : undefined"
|
|
|
|
|
|
:aria-label="`第${index + 1}步:${step}`"
|
|
|
|
|
|
@click="setCurrentStep(index + 1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
|
|
|
|
|
|
<span class="step-copy">
|
|
|
|
|
|
<strong class="step-name" :title="step">{{ step }}</strong>
|
|
|
|
|
|
<span class="step-action">{{ activeStep === index + 1 ? "当前步骤" : "点击切换" }} <span aria-hidden="true">→</span></span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span v-if="index < configuredSteps.length - 1" class="step-connector" aria-hidden="true"></span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</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; margin-left: auto; 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 { position: relative; display: flex; align-items: center; gap: 0; width: calc(100% + 24px); min-height: 0; margin: 0 -12px 32px; padding: 18px 22px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, .3); border-radius: 18px; background: linear-gradient(90deg, rgba(6, 42, 61, .58), rgba(1, 18, 31, .42)), rgba(0, 0, 0, .3); box-shadow: inset 0 1px 0 rgba(145, 233, 255, .08); scrollbar-width: thin; }
|
|
|
|
|
|
.step-tab { display: flex; flex: 1 0 145px; align-items: center; min-width: 0; min-height: 66px; padding: 8px 10px; border: 1px solid rgba(68, 141, 177, .35); border-radius: 8px; color: #9ab3d0; background: rgba(3, 27, 43, .52); box-shadow: inset 0 1px 0 rgba(135, 221, 255, .04); font: inherit; font-size: 14px; font-weight: 800; text-align: left; cursor: pointer; transition: transform .2s ease, color .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease; }
|
|
|
|
|
|
.step-tab:hover { transform: translateY(-2px); border-color: rgba(89, 223, 255, .82); color: #fff; background: rgba(8, 92, 131, .52); box-shadow: 0 7px 18px rgba(0, 0, 0, .24), inset 0 1px 0 rgba(152, 239, 255, .16); }
|
|
|
|
|
|
.step-tab:hover .step-action { color: #9bf4ff; }
|
|
|
|
|
|
.step-tab:focus-visible { outline: 2px solid rgba(92, 224, 255, .7); outline-offset: 2px; }
|
|
|
|
|
|
.step-tab.active { border-color: rgba(89, 226, 255, 0.85); color: #fff; background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64)); box-shadow: 0 0 18px rgba(24, 183, 235, .22), inset 0 1px 0 rgba(176, 246, 255, .2); }
|
|
|
|
|
|
.step-marker { display: inline-flex; flex: 0 0 38px; align-items: center; justify-content: center; width: 38px; height: 38px; border: 1px solid rgba(101, 176, 208, .6); border-radius: 50%; color: #a6c4d9; background: rgba(3, 25, 40, .8); box-shadow: inset 0 0 12px rgba(38, 174, 233, .08); font-size: 12px; font-weight: 900; letter-spacing: .04em; transition: border-color .2s ease, color .2s ease, background .2s ease, box-shadow .2s ease; }
|
|
|
|
|
|
.step-copy { display: grid; min-width: 0; gap: 4px; margin-left: 10px; }
|
|
|
|
|
|
.step-name { display: -webkit-box; overflow: hidden; color: inherit; font-size: 14px; font-weight: 800; line-height: 1.25; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
|
|
|
|
|
.step-action { color: #66b6d4; font-size: 10px; font-weight: 800; letter-spacing: .06em; line-height: 1; transition: color .2s ease; }
|
|
|
|
|
|
.step-tab.active .step-marker { border-color: #72e8ff; color: #fff; background: linear-gradient(135deg, #08b7e8, #1375d0); box-shadow: 0 0 0 5px rgba(25, 179, 237, .14), 0 0 18px rgba(46, 208, 255, .58); }
|
|
|
|
|
|
.step-tab.complete .step-marker { border-color: rgba(84, 230, 221, .85); color: #062b37; background: #6ef0e1; box-shadow: 0 0 12px rgba(79, 231, 218, .35); }
|
|
|
|
|
|
.step-connector { position: relative; flex: 0 1 40px; min-width: 18px; height: 2px; margin: 0 8px; overflow: visible; background: rgba(98, 169, 201, .34); }
|
|
|
|
|
|
.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ""; transform: translateY(-50%) rotate(45deg); }
|
|
|
|
|
|
.step-tab.complete + .step-connector { background: linear-gradient(90deg, #6ef0e1, #1cb8e9); box-shadow: 0 0 9px rgba(53, 218, 235, .46); }
|
|
|
|
|
|
.step-tab.complete + .step-connector::after { border-color: #63e6f1; }
|
|
|
|
|
|
.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 { align-items: stretch; flex-direction: column; width: 100%; margin-right: 0; margin-left: 0; overflow: visible; border-radius: 20px; } .step-tab { flex: 0 0 auto; width: 100%; } .step-connector { flex: 0 0 20px; min-width: 0; width: 2px; height: 20px; margin-left: 29px; text-align: center; writing-mode: vertical-rl; } .info-grid, .category-grid, .role-grid, .inline-fields { grid-template-columns: 1fr; } .role-card--wide { grid-column: span 1; } }
|
|
|
|
|
|
.step-tab.active { border-color:rgba(89,226,255,.85); color:#fff; background:linear-gradient(115deg,rgba(4,109,155,.78),rgba(5,62,102,.64)); box-shadow:0 0 18px rgba(24,183,235,.22),inset 0 1px 0 rgba(176,246,255,.2); }.step-tab.active .step-marker { border-color:#72e8ff; color:#fff; background:linear-gradient(135deg,#08b7e8,#1375d0); box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px rgba(46,208,255,.58); }.step-tab.complete { color:#dffbff; }.step-tab.complete .step-marker { border-color:rgba(84,230,221,.85); color:#062b37; background:#6ef0e1; box-shadow:0 0 12px rgba(79,231,218,.35); }.step-connector.complete { background:linear-gradient(90deg,#6ef0e1,#1cb8e9); color:#63e6f1; box-shadow:0 0 9px rgba(53,218,235,.46); }
|
|
|
|
|
|
.workbench-head--actions-only {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
margin-bottom: 18px;
|
|
|
|
|
|
padding-bottom: 0;
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|