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/channel/index.vue

781 lines
42 KiB
Vue

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden 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="channel student-training-shell">
<main class="training-core">
<section class="task-header"><div class="task-badge"><el-icon><TrendCharts /></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="hasConfiguredSteps" 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 class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p style="font-size: 16px; font-weight: normal">季节指数预测法,亦称“温斯特法”,是根据时间序列中的数据资料所呈现的季节变动规律性,对预测目标未来状况作出预测的方法。它首先建立预测模型来求得历史上各期的趋势值,然后用实际值除以趋势值来求出季节指数。若是有两个以上的波动周期,还需要求出相同时期的季节指数平均值,再用它去修正预测值。</p>
<div class="caseBacktitle">
<img src="@/assets/images/步骤.png" alt="" />
<span>实验实训</span>
</div>
<p>假设某电商平台店铺主营夏季产品,如泳装、泳帽、泳镜、游泳圈等产品,这类产品具有明显的季节波动性,公司需要预测未来12个月的采购需求,请使用季节指数预测法完成任务。</p>
<p>
应用步骤
<br />
数据收集:收集历史采购数据,确保数据覆盖多个完整的季节周期。
<br />
数据预处理:清理数据,处理缺失值和异常值。
<br />
计算移动平均值:计算每个时间点的移动平均值,以平滑数据。
<br />
计算季节指数:通过将实际值除以移动平均值,计算每个月份的季节指数。
<br />
预测未来需求:利用季节指数和趋势数据预测未来的采购需求。
<br />
</p>
<div class="online">
<span>1.数据准备</span>
<el-button type="primary" class="ml50" @click="downloadData">数据下载</el-button>
</div>
<p>公司2022到2024年三年期间月度采购量如下所示:</p>
<el-table
:data="advertisementData"
border
style="width: 90%"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="year" label="年份" align="center"></el-table-column>
<el-table-column prop="jan" label="1月" align="center"></el-table-column>
<el-table-column prop="feb" label="2月" align="center"></el-table-column>
<el-table-column prop="mar" label="3月" align="center"></el-table-column>
<el-table-column prop="apr" label="4月" align="center"></el-table-column>
<el-table-column prop="may" label="5月" align="center"></el-table-column>
<el-table-column prop="jun" label="6月" align="center"></el-table-column>
<el-table-column prop="jul" label="7月" align="center"></el-table-column>
<el-table-column prop="aug" label="8月" align="center"></el-table-column>
<el-table-column prop="sep" label="9月" align="center"></el-table-column>
<el-table-column prop="oct" label="10月" align="center"></el-table-column>
<el-table-column prop="nov" label="11月" align="center"></el-table-column>
<el-table-column prop="dec" label="12月" align="center"></el-table-column>
</el-table>
<p>
数据特征说明‌:
<br />
‌季节性规律‌
<br />
‌旺季(6-8月)‌:夏季需求高峰,采购量逐月攀升,7月达年度峰值(如2023年7月920件);
<br />
‌淡季(12-2月)‌:冬季需求低迷,采购量显著低于其他月份(如2024年1月140件)。
<br />
</p>
<p>
年度增长趋势‌
<br />
每年采购量整体增长约 ‌5%-10%‌,符合行业稳定扩张特征(如2023年6月较2022年增长8.3%);
<br />
冬季淡季增速较低(如2023年12月较2022年增长5.6%),夏季旺季增速较高(如2024年7月较2023年增长3.3%)。
<br />
</p>
<p>
过渡月份波动‌
<br />
春季(3-5月)和秋季(9-11月)为过渡期,采购量随气温变化逐步上升或下降(如4月采购量介于300-400件);
<br />
9月后需求快速回落,反映季节性商品换季特征(如2022年9月采购量环比下降33%)
<br />
</p>
</section><section v-show="isSectionVisible(2)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>月度数据我们采用12期移动平均,计算简单移动平均值数据,计算结果取整:</p>
<el-table
:data="tableData"
border
style="width: 50%"
height="600"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="month" label="月份" align="center"></el-table-column>
<el-table-column prop="actual" label="实际采购量" align="center"></el-table-column>
<el-table-column prop="ma12" label="MA(12)12期移动平均值" align="center">
<template #default="scope">
<el-input v-model="scope.row.ma12" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
</section><section v-show="isSectionVisible(3)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>计算季节指数,先用上述各月份实际值除以移动平均值,计算季节性不规则成分,然后按月份分组求均值,得出初版季节性指数,最后再用各月份初版季节性指数除以初版季节性指数的均值,得出标准化季节性指数,请在下表填写标准化季节性指数(结果保留3位小数):</p>
<el-table
:data="tableData1"
border
style="width: 50%"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="month" label="月份" align="center"></el-table-column>
<el-table-column prop="index" label="标准化季节性指数" align="center">
<template #default="scope">
<el-input v-model="scope.row.index" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
</section><section v-show="isSectionVisible(4)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>未来需求预测,使用线性回归模型预测2025年采购量:</p>
<el-table
:data="forecastData"
border
style="width: 50%"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="month" label="月份" align="center"></el-table-column>
<el-table-column prop="forecast" label="预测采购量" align="center">
<template #default="scope">
<el-input v-model="scope.row.forecast" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<p>
<el-button @click="drawForecastChart" type="primary">点击进行采购量预测可视化</el-button>
<el-button @click="runResultShow = true" type="primary">查看代码</el-button>
<el-button @click="go">跳转到Python在线编程环境</el-button>
</p>
<div id="forecastChart" ref="forecastChart" style="width: 80%; height: 400px"></div>
</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="taskfooter"><el-button @click="submitTask" :loading="saving">提交任务</el-button><el-button @click="resetTask" :loading="saving">清空填写</el-button></div>
<pop-model :showModel="runResultShow" title="设置代码" @closePop="closeCode">
<template v-slot:content>
<div>
<codemirror :code="code"></codemirror>
</div>
</template>
</pop-model>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:依次完成移动平均、季节指数和未来采购量预测,再使用图表检查预测趋势。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import * as analysisAPI from "@/api/market-demand-mining.js";
import * as echarts from "echarts";
import useUserStore from "@/store/modules/user";
import { ArrowLeft, ArrowRight, CircleCheck, Download, TrendCharts } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const userStore = useUserStore();
const dialogVisible = ref(false);
import codemirror from "@/components/codemirror/index.vue";
import popModel from "@/views/components/popModal.vue";
const { proxy } = getCurrentInstance();
const TASK_KEY = "purchase-demand-forecast";
const taskConfig = ref(null);
const activeStep = ref(1);
const saving = ref(false);
const taskTitle = computed(() => taskConfig.value?.taskName || "采购需求预测");
const taskBackground = computed(() => taskConfig.value?.background || "电商产品采购需求预测用于确定未来一段时间的商品需求量,帮助企业合理安排采购计划,避免库存积压或缺货。" );
const taskRequirement = computed(() => taskConfig.value?.requirements || "使用季节指数预测法,根据历史月度采购数据预测未来一年的采购量。" );
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解季节指数预测法的核心步骤", "能够计算移动平均与季节指数", "能够预测未来采购需求并输出结论"]));
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((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
const runResultShow = ref(false);
const code = ` X
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
from sklearn.linear_model import LinearRegression
mpl.rcParams['font.sans-serif'] = ['SimHei']#SimHeri是中文字体名称
mpl.rcParams['axes.unicode_minus'] = False
# ====================== 1. 数据准备 ======================
data = {
"Year": [2022]*12 + [2023]*12 + [2024]*12,
"Month": list(range(1,13)) * 3,
"Purchase": [
120, 150, 280, 350, 480, 720, 850, 800, 600, 400, 250, 180, # 2022
130, 160, 300, 380, 520, 780, 920, 870, 640, 430, 270, 190, # 2023
140, 170, 320, 400, 550, 830, 950, 900, 680, 460, 290, 200 # 2024
]
}
df = pd.DataFrame(data)
df["Date"] = pd.to_datetime(df["Year"].astype(str) + "-" + df["Month"].astype(str))
df = df.set_index("Date").sort_index()
# ====================== 2. 计算移动平均值(取整) ======================
# 计算移动平均(计算时保留小数,输出时取整)
df["MA12"] = df["Purchase"].rolling(window=12, center=True).mean()
# ====================== 3. 输出移动平均值(整数形式) ======================
print("历史数据与移动平均值(取整):")
valid_ma = df.dropna(subset=["MA12"]).copy()
# 对移动平均值四舍五入取整
valid_ma["MA12"] = valid_ma["MA12"].round().astype(int) # 关键修改点
print(valid_ma[["Purchase", "MA12"]])
# ====================== 4. 季节指数计算 ======================
# 计算时仍使用原始移动平均值保证精度
df["Seasonal_Irregular"] = df["Purchase"] / df["MA12"]
seasonal_index = df.groupby("Month")["Seasonal_Irregular"].mean()
seasonal_index = (seasonal_index / seasonal_index.mean()).round(3)
# ====================== 5. 趋势预测 ======================
trend_data = df.dropna(subset=["MA12"]).copy()
trend_data["Time"] = np.arange(len(trend_data))
model = LinearRegression()
model.fit(trend_data[["Time"]], trend_data["MA12"])
future_time = np.arange(len(trend_data), len(trend_data)+12).reshape(-1, 1)
future_trend = model.predict(future_time)
# ====================== 6. 预测采购量(取整数) ======================
future_dates = pd.date_range(start="2025-01-01", periods=12, freq="MS")
future_df = pd.DataFrame({"Date": future_dates})
future_df["Month"] = future_df["Date"].dt.month
future_df["Trend"] = future_trend.round(1) # 趋势值保留1位小数用于后续计算
future_df["Season_Index"] = future_df["Month"].map(seasonal_index)
future_df["Forecast"] = (future_df["Trend"] * future_df["Season_Index"]).round().astype(int)
# ====================== 7. 可视化与输出 ======================
plt.figure(figsize=(14, 7))
ax = plt.gca()
# 绘制历史数据与趋势线
plt.plot(df.index, df["Purchase"], label="历史采购量", marker="o", linestyle="-", markersize=6)
plt.plot(trend_data.index, trend_data["MA12"], label="移动平均趋势", linestyle="--", color="darkgreen")
# 绘制预测数据点并添加数值标签
forecast_line = plt.plot(future_df["Date"], future_df["Forecast"],
label="2025预测值", color="red", marker="o", linestyle="-")
# 在每个预测点上添加数值标签
for date, value in zip(future_df["Date"], future_df["Forecast"]):
ax.text(date, value + 20, f'{value}', # 数值显示在点上方20单位
ha='center', va='bottom', fontsize=9, color='red',
bbox=dict(facecolor='white', edgecolor='none', alpha=0.8)) # 白色背景框
# 坐标轴与图例设置
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3)) # 每3个月显示一个刻度
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
plt.xticks(rotation=45)
plt.title("采购量预测可视化", fontsize=14)
plt.xlabel("日期", fontsize=12)
plt.ylabel("采购量(件)", fontsize=12)
plt.grid(True, linestyle='--', alpha=0.5)
plt.legend(loc='upper left')
plt.tight_layout()
plt.show()
print("\n季节指数(保留3位小数):")
print(seasonal_index)
print("\n2025年预测采购量(整数):")
print(future_df[["Date", "Forecast"]].set_index("Date"))`;
const advertisementData = [
{
year: 2022,
jan: 120,
feb: 150,
mar: 280,
apr: 350,
may: 480,
jun: 720,
jul: 850,
aug: 800,
sep: 600,
oct: 400,
nov: 250,
dec: 180,
},
{
year: 2023,
jan: 130,
feb: 160,
mar: 300,
apr: 380,
may: 520,
jun: 780,
jul: 920,
aug: 870,
sep: 640,
oct: 430,
nov: 270,
dec: 190,
},
{
year: 2024,
jan: 140,
feb: 170,
mar: 320,
apr: 400,
may: 550,
jun: 830,
jul: 950,
aug: 900,
sep: 680,
oct: 460,
nov: 290,
dec: 200,
},
];
const tableDatas = ref([
{ month: "2022年1月", actual: 120 },
{ month: "2022年2月", actual: 150 },
{ month: "2022年3月", actual: 280 },
{ month: "2022年4月", actual: 350 },
{ month: "2022年5月", actual: 480 },
{ month: "2022年6月", actual: 720 },
{ month: "2022年7月", actual: 850 },
{ month: "2022年8月", actual: 800 },
{ month: "2022年9月", actual: 600 },
{ month: "2022年10月", actual: 400 },
{ month: "2022年11月", actual: 250 },
{ month: "2022年12月", actual: 180 },
{ month: "2023年1月", actual: 130 },
{ month: "2023年2月", actual: 160 },
{ month: "2023年3月", actual: 300 },
{ month: "2023年4月", actual: 380 },
{ month: "2023年5月", actual: 520 },
{ month: "2023年6月", actual: 780 },
{ month: "2023年7月", actual: 920 },
{ month: "2023年8月", actual: 870 },
{ month: "2023年9月", actual: 640 },
{ month: "2023年10月", actual: 430 },
{ month: "2023年11月", actual: 270 },
{ month: "2023年12月", actual: 190 },
{ month: "2024年1月", actual: 140 },
{ month: "2024年2月", actual: 170 },
{ month: "2024年3月", actual: 320 },
{ month: "2024年4月", actual: 400 },
{ month: "2024年5月", actual: 550 },
{ month: "2024年6月", actual: 830 },
{ month: "2024年7月", actual: 950 },
{ month: "2024年8月", actual: 900 },
{ month: "2024年9月", actual: 680 },
{ month: "2024年10月", actual: 460 },
{ month: "2024年11月", actual: 290 },
{ month: "2024年12月", actual: 200 },
]);
const tableData = ref([
{ month: "2022年7月", actual: 850, ma12: null },
{ month: "2022年8月", actual: 800, ma12: null },
{ month: "2022年9月", actual: 600, ma12: null },
{ month: "2022年10月", actual: 400, ma12: null },
{ month: "2022年11月", actual: 250, ma12: null },
{ month: "2022年12月", actual: 180, ma12: null },
{ month: "2023年1月", actual: 130, ma12: null },
{ month: "2023年2月", actual: 160, ma12: null },
{ month: "2023年3月", actual: 300, ma12: null },
{ month: "2023年4月", actual: 380, ma12: null },
{ month: "2023年5月", actual: 520, ma12: null },
{ month: "2023年6月", actual: 780, ma12: null },
{ month: "2023年7月", actual: 920, ma12: null },
{ month: "2023年8月", actual: 870, ma12: null },
{ month: "2023年9月", actual: 640, ma12: null },
{ month: "2023年10月", actual: 430, ma12: null },
{ month: "2023年11月", actual: 270, ma12: null },
{ month: "2023年12月", actual: 190, ma12: null },
{ month: "2024年1月", actual: 140, ma12: null },
{ month: "2024年2月", actual: 170, ma12: null },
{ month: "2024年3月", actual: 320, ma12: null },
{ month: "2024年4月", actual: 400, ma12: null },
{ month: "2024年5月", actual: 550, ma12: null },
{ month: "2024年6月", actual: 830, ma12: null },
{ month: "2024年7月", actual: 950, ma12: null },
]);
const tableData1 = ref([
{ month: "1月", index: null },
{ month: "2月", index: null },
{ month: "3月", index: null },
{ month: "4月", index: null },
{ month: "5月", index: null },
{ month: "6月", index: null },
{ month: "7月", index: null },
{ month: "8月", index: null },
{ month: "9月", index: null },
{ month: "10月", index: null },
{ month: "11月", index: null },
{ month: "12月", index: null },
]);
const forecastData = ref([
{ month: "2025年01月", forecast: "" },
{ month: "2025年02月", forecast: "" },
{ month: "2025年03月", forecast: "" },
{ month: "2025年04月", forecast: "" },
{ month: "2025年05月", forecast: "" },
{ month: "2025年06月", forecast: "" },
{ month: "2025年07月", forecast: "" },
{ month: "2025年08月", forecast: "" },
{ month: "2025年09月", forecast: "" },
{ month: "2025年10月", forecast: "" },
{ month: "2025年11月", forecast: "" },
{ month: "2025年12月", forecast: "" },
]);
// 生成完整的月份列表(包括 2024年空隙)
const getFullMonths = () => {
const months = [];
const startDate = new Date("2022-01-01");
const endDate = new Date("2025-12-01");
while (startDate <= endDate) {
months.push(`${startDate.getFullYear()}年${(startDate.getMonth() + 1).toString().padStart(2, "0")}月`);
startDate.setMonth(startDate.getMonth() + 1);
}
return months;
};
/// 合并数据并生成时间轴
const chartData = computed(() => {
const fullMonths = getFullMonths();
const actualData = tableDatas.value.map((item) => item.actual);
const forecastDataArray = Array(fullMonths.length).fill(null);
forecastData.value.forEach((item, index) => {
const idx = fullMonths.indexOf(item.month);
if (idx !== -1) forecastDataArray[idx] = item.forecast;
});
const ma12Data = tableData.value.map((item) => item.ma12);
// ma12Data数组头部添加
ma12Data.unshift(null, null, null, null, null, null);
return {
actual: actualData,
forecast: forecastDataArray,
ma12: ma12Data,
months: fullMonths,
};
});
const drawForecastChart = () => {
let myChart = echarts.init(proxy.$refs.forecastChart);
const option = {
title: {
text: "采购量预测可视化",
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: {
trigger: "axis",
formatter: function (params) {
let result = `${params[0].axisValue}<br/>`;
params.forEach((param) => {
result += `${param.marker} ${param.seriesName}: ${param.value || "N/A"}${param.seriesName === "指数" ? "" : ""}<br/>`;
});
return result;
},
},
legend: {
data: ["历史采购量", "移动平均趋势", "2025预测值"],
top: "8%",
// 下移一点点
},
grid: {
left: "3%",
right: "4%",
bottom: "1%",
containLabel: true,
},
xAxis: {
type: "category",
data: chartData.value.months,
axisLabel: { interval: 0, rotate: 45 },
},
yAxis: [
{
type: "value",
name: "采购量(件)",
axisLabel: { formatter: "{value}" },
nameGap: 40,
min: 0,
nameLocation: "middle",
},
],
series: [
{
name: "历史采购量",
type: "line",
yAxisIndex: 0,
data: chartData.value.actual,
itemStyle: { color: "#5470c6" },
lineStyle: { width: 2 },
connectNulls: false, // 不连接空值
},
{
name: "移动平均趋势",
type: "line",
yAxisIndex: 0,
data: chartData.value.ma12,
itemStyle: { color: "#91cc75" },
lineStyle: { type: "dashed", width: 2 },
connectNulls: false, // 不连接空值
},
{
name: "2025预测值",
type: "line",
yAxisIndex: 0,
data: chartData.value.forecast,
itemStyle: { color: "#ee6666" },
lineStyle: { width: 2 },
connectNulls: false, // 不连接空值
},
],
};
myChart.setOption(option);
// 响应式调整
window.addEventListener("resize", () => myChart.resize());
};
// 下载数据
const downloadData = () => {
window.open(`${import.meta.env.VITE_APP_BASE_API}api/cgxqyc/exportCgxqycData`);
};
function normalizeActiveStep(value) { if (!configuredSteps.value.length) return 1; return Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
function isSectionVisible(section) { return !hasConfiguredSteps.value || activeStep.value === section; }
function buildProgress() { return { activeStep: activeStep.value, tableData: tableData.value, tableData1: tableData1.value, forecastData: forecastData.value, updatedAt: new Date().toISOString() }; }
function applyProgress(value) {
if (!value || typeof value !== "object") return;
activeStep.value = normalizeActiveStep(value.activeStep);
if (Array.isArray(value.tableData)) tableData.value = value.tableData;
if (Array.isArray(value.tableData1)) tableData1.value = value.tableData1;
if (Array.isArray(value.forecastData)) forecastData.value = value.forecastData;
}
async function persistProgress(saveAction = "SAVE", silent = false) {
await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()) });
if (!silent) proxy?.$modal?.msgSuccess(saveAction === "SUBMIT" ? "提交成功" : "保存成功");
}
function setCurrentStep(step) { activeStep.value = normalizeActiveStep(step); persistProgress("SAVE", true).catch(() => {}); }
function goPreviousStep() { setCurrentStep(activeStep.value - 1); }
function goNextStep() { setCurrentStep(activeStep.value + 1); }
async function saveCurrentProgress() { if (saving.value) return; saving.value = true; try { await persistProgress(); } finally { saving.value = false; } }
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; } }
async function loadSavedProgress() { try { const res = await getStudentTrainingAnswer(TASK_KEY); const raw = res?.data?.step1Answer; if (raw) applyProgress(JSON.parse(raw)); } catch (error) { /* Historical progress is optional. */ } }
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); }
function closeCode() { runResultShow.value = false; }
// 提交
const submitTask = async () => {
// 获取monthlyData月度数据
const monthlyData = tableData.value.map((item) => item.ma12);
// 获取forecastData预测数据
const seasonalIndex = tableData1.value.map((item) => item.index);
// 调用接口
const demandForecast = forecastData.value.map((item) => item.forecast);
// 判断都填写完毕了没
if (monthlyData.includes(null) || seasonalIndex.includes(null) || demandForecast.includes(null)) {
proxy.$modal.msgWarning("请填写完整的数据");
return;
}
if (saving.value) return;
saving.value = true;
try {
await analysisAPI.procurementDemandForecastSubmit({
monthlyData: JSON.stringify(monthlyData),
seasonalIndex: JSON.stringify(seasonalIndex),
demandForecast: JSON.stringify(demandForecast),
userId: userStore.userInfo.userId,
});
await persistProgress("SUBMIT");
} finally { saving.value = false; }
};
// 回显
const echoData = async () => {
try {
const res = await analysisAPI.procurementDemandForecastEcho({ userId: userStore.userInfo.userId });
if (!res.data) return;
const { monthlyData, seasonalIndex, demandForecast } = res.data;
JSON.parse(monthlyData).forEach((item, index) => {
tableData.value[index].ma12 = item;
forecastData.value[index].forecast = JSON.parse(demandForecast)[index];
});
JSON.parse(seasonalIndex).forEach((item, index) => {
tableData1.value[index].index = item;
});
} catch (error) {
// The standard page remains usable without legacy submitted data.
}
};
// 重新实训
const resetTask = async () => {
if (saving.value) return;
saving.value = true;
try {
await analysisAPI.procurementDemandForecastReTrain({ userId: userStore.userInfo.userId });
// 清空对应数据
forecastData.value.forEach((item) => {
item.forecast = null;
});
tableData.value.forEach((item) => {
item.ma12 = null;
});
tableData1.value.forEach((item) => {
item.index = null;
});
activeStep.value = 1;
await persistProgress("RESET", true);
proxy.$modal.msgSuccess("清空填写成功");
} finally { saving.value = false; }
};
const go = () => {
window.open("https://jrdsj.sztzjy.com:8000/user/zy84cf311/lab");
};
onMounted(async () => {
await loadTaskConfig();
await echoData();
await loadSavedProgress();
});
</script>
<style lang="scss" scoped>
.channel {
--channel-panel: rgba(4, 31, 46, 0.78);
--channel-panel-strong: rgba(3, 20, 31, 0.95);
--channel-line: rgba(54, 174, 221, 0.42);
min-height: calc(100vh - 84px);
padding: 0 0 32px;
color: #d7ecff;
background:
radial-gradient(circle at 10% 0%, rgba(15, 129, 189, 0.16), transparent 34%),
linear-gradient(180deg, rgba(3, 17, 27, 0.94), rgba(5, 19, 30, 0.98));
:deep(.training-step-area) {
margin-top: 0 !important;
border-color: var(--channel-line) !important;
background: var(--channel-panel) !important;
box-shadow: inset 0 1px 0 rgba(129, 231, 255, 0.12), 0 18px 38px rgba(0, 0, 0, 0.2) !important;
}
:deep(.el-table) {
--el-table-border-color: rgba(63, 164, 207, 0.36);
--el-table-header-bg-color: #0b577f;
--el-table-header-text-color: #edfbff;
--el-table-row-hover-bg-color: rgba(15, 107, 149, 0.32);
--el-table-bg-color: rgba(2, 20, 31, 0.82);
--el-table-tr-bg-color: rgba(2, 20, 31, 0.82);
--el-table-text-color: #d7ecff;
overflow: hidden;
margin-top: 12px;
border: 1px solid var(--channel-line);
border-radius: 12px;
background: var(--channel-panel-strong);
}
:deep(.el-table::before),
:deep(.el-table__inner-wrapper::before) {
background-color: transparent;
}
:deep(.el-table th.el-table__cell) {
color: #edfbff !important;
background: linear-gradient(180deg, #0f76a9, #0a567e) !important;
font-weight: 800;
}
:deep(.el-table td.el-table__cell),
:deep(.el-table tr) {
color: #d7ecff !important;
background: rgba(2, 20, 31, 0.82) !important;
}
:deep(.el-table .cell) {
line-height: 1.65;
}
:deep(.el-input__wrapper) {
min-height: 36px;
border: 1px solid rgba(76, 169, 211, 0.48);
border-radius: 7px;
box-shadow: none !important;
background: var(--channel-panel-strong);
}
:deep(.el-input__inner) {
color: #ebf8ff;
}
:deep(.el-input__inner::placeholder) {
color: #7194a8;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #4edbff;
box-shadow: 0 0 0 3px rgba(50, 205, 255, 0.12) !important;
}
:deep(.el-button) {
border-color: rgba(55, 174, 221, 0.72) !important;
color: #e9faff !important;
background: linear-gradient(180deg, #168ab9, #0c628d) !important;
}
:deep(.el-button:hover) {
border-color: #65e5ff !important;
color: #ffffff !important;
background: linear-gradient(180deg, #1b9ece, #0f709f) !important;
}
.online {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(44, 141, 182, 0.3);
border-radius: 10px;
background: rgba(1, 17, 27, 0.3);
}
.online span {
color: #d7ecff;
}
.taskfooter {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 28px;
margin-top: 32px;
padding-top: 28px;
border-top: 1px solid rgba(55, 174, 221, 0.24);
}
.taskfooter :deep(.el-button) {
min-width: 172px;
height: 56px;
border-radius: 8px;
font-size: 18px;
font-weight: 800;
}
}
.channel {
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,.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; }.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; justify-content: space-between; align-items: flex-start; 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; }.workbench-head h3 { margin: 4px 0 8px; color: #fff; font-size: 26px; font-weight: 800; line-height: 1.3; }.workbench-description { 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 { 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; }.step-indicator { display:flex; align-items:center; gap:12px; 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(110deg,rgba(4,39,58,.9),rgba(2,20,33,.94)); box-shadow:inset 0 1px 0 rgba(169,229,255,.08); }.step-tab { display:flex; flex:1 0 145px; align-items:center; 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); font:inherit; font-size:14px; font-weight:800; text-align:left; cursor:pointer; transition:transform .2s,color .2s,background .2s,border-color .2s,box-shadow .2s; }.step-tab:hover,.step-tab:focus-visible { transform:translateY(-2px); border-color:rgba(89,223,255,.82); color:#fff; background:rgba(8,92,131,.52); box-shadow:0 8px 18px rgba(0,0,0,.2); outline:none; }.step-tab.active { border-color:rgba(89,226,255,.85); color:#fff; background:linear-gradient(95deg,#0a6b9e,#0a5c86); box-shadow:0 8px 20px rgba(0,120,170,.22); }.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid rgba(113,223,255,.55); border-radius:50%; color:#bdefff; background:rgba(3,27,43,.72); font-size:13px; font-weight:900; }.step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }.step-copy strong { display:-webkit-box; overflow:hidden; color:inherit; font-size:14px; line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.step-copy span { color:#7ec7e7; font-size:12px; font-weight:700; }.step-tab.active .step-marker { border-color:#a8f3ff; color:#fff; background:linear-gradient(135deg,#0bb2ed,#0a6b9e); box-shadow:0 0 0 4px rgba(77,215,255,.13),0 0 18px rgba(77,215,255,.4); }.step-tab.complete .step-marker { border-color:#59e0ca; color:#dffbff; background:rgba(13,132,119,.5); }.step-connector { flex:0 0 28px; height:2px; background:rgba(77,167,220,.54); color:#78cfe9; font-size:16px; line-height:0; text-align:right; }.step-connector.complete { background:#55ddcc; color:#75f0df; }.step-card { margin-top: 32px; padding: 18px 22px 22px; }.step-indicator + .step-card,.workbench-head + .step-card { margin-top: 0; }.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; }.caseBacktitle { display: flex; align-items: center; gap: 8px; margin: 20px 0 10px; color: #dff5ff; font-size: 16px; font-weight: 800; }.caseBacktitle img { display: none; }.step-card > p { color: #d7ecff; font-size: 15px; line-height: 1.8; }
:deep(.el-table) { width: 100% !important; max-width: 100%; border-radius: 18px; }:deep(.el-table th.el-table__cell) { background: rgba(8,55,82,.98) !important; }:deep(.el-input__wrapper) { min-height: 42px; border-radius: 14px; background: #0f1a24; }.online { border-radius: 14px; background: rgba(1,14,26,.72); }.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; }.taskfooter { gap: 52px; margin-top: 36px; padding-top: 0; border-top: 0; }.taskfooter :deep(.el-button) { min-width: 172px; height: 64px; border: 1px solid #2c83aa !important; border-radius: 6px; color: #dff7ff !important; background: rgba(13,64,88,.62) !important; font-size: 24px; font-weight: 900; }
}
@media (max-width: 1100px) { .channel { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .channel { 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,.taskfooter :deep(.el-button) { 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 22px; width:2px; height:22px; margin-left:29px; text-align:center; writing-mode:vertical-rl; }.taskfooter { gap: 14px; } } }
.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); }
/* Standard page step navigation */
.step-indicator { position:relative; display:flex; align-items:center; gap:0; width:calc(100% + 24px); 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; }
.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; }
.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; }
@media (max-width:900px) { .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; } }
.workbench-head--actions-only {
justify-content: flex-end;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
}
</style>