|
|
|
|
|
<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>
|