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

1692 lines
69 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="channel student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTask" :on-export="exportOutcome" />
</section>
<section class="task-card">
<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 class="linear-forecast-intro">线性预测的核心是拟合趋势线(y=a+bt),是基于历史数据预测未来销量的有效方法。根据案例资料及数据,预测案例店铺产品未来 3 个月市场需求数据。</p>
<div class="linear-forecast-panel">
<el-table :data="linearForecastRows" border class="linear-forecast-table">
<el-table-column prop="label" label="预测月份" align="center" width="310" />
<el-table-column label="预测案例店铺市场需求量(取整)" align="center">
<template #default="{ row }">
<el-input-number v-model="row.demand" :min="0" :precision="0" :step="1" :controls="false" placeholder="请输入非负整数" class="linear-forecast-input" />
</template>
</el-table-column>
</el-table>
</div>
<section class="linear-forecast-guide">
<div>
<p class="guide-title">可参考以下方法:</p>
<ol>
<li>下载案例数据表,使用 Excel 中的 FORECAST.LINEAR 函数(WPS 为 FORECAST 函数),自行预测未来 3 个月市场需求。</li>
<li>导出实训数据表,上传到通用 AI 大模型,通过大模型计算结果。参考提示词如下:请根据 12 个月销量数据,利用线性预测法预测未来三个月的销量数据,给出计算过程。</li>
</ol>
</div>
<el-button type="primary" class="practice-guide-button" @click="downloadPracticeGuide">下载实训操作说明</el-button>
</section>
</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 class="forecast-visual-intro">计算未来 3 个月需求量后,使用散点图 + 趋势线绘制需求量预测趋势图,呈现已知的 12 个月销量以及预测数据的整体走势。</p>
<section class="forecast-visual-upload-card">
<label v-if="!demandForecastChart.url" class="forecast-visual-dropzone" :class="{ 'is-uploading': uploadingDemandForecastChart }">
<input type="file" accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp" :disabled="uploadingDemandForecastChart" @change="handleDemandForecastChartUpload" />
<span class="forecast-visual-upload-icon" aria-hidden="true">↥</span>
<strong>{{ uploadingDemandForecastChart ? "正在上传图形..." : "上传需求预测可视化图形" }}</strong>
<span>支持 PNG、JPG、JPEG、WEBP 格式,上传后可点击缩略图预览</span>
</label>
<div v-else class="forecast-visual-preview">
<el-image :src="demandForecastChart.url" :preview-src-list="[demandForecastChart.url]" :initial-index="0" fit="cover" preview-teleported />
<div class="forecast-visual-file-meta">
<strong>{{ demandForecastChart.name || "需求预测可视化图形" }}</strong>
<span>点击图形可放大预览</span>
<button type="button" class="forecast-visual-replace" @click="clearDemandForecastChart">重新上传</button>
</div>
</div>
</section>
<section class="forecast-visual-guide">
<div>
<p class="guide-title">可参考以下方法:</p>
<ol>
<li>使用 Excel/WPS 绘图功能实现可视化,可参考《实训操作说明》完成。</li>
<li>下载实训数据表,加上预测的三个月需求数据,表格整体数据上传到通用 AI 大模型,通过大模型计算结果生成可视化图形。参考提示词:请根据表格数据,生成数据趋势图,要求使用散点图加趋势图绘制数据走势。</li>
</ol>
</div>
<el-button type="primary" class="practice-guide-button" @click="downloadPracticeGuide">下载实训操作说明</el-button>
</section>
</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 class="procurement-plan-intro">基于步骤一预测的未来3个月销量,结合安全库存和期初库存,计算各月采购量,填报采购预测表。</p>
<section class="procurement-plan-rules">
<h4>采购规则</h4>
<ol>
<li>安全库存:按预测销量的 10% 预留,应对突发订单波动(安全库存 = 预测销量 × 10%,四舍五入取整)。</li>
<li>补货周期:按月采购,每月月初下达采购订单。</li>
<li>期初库存:第 12 月末剩余库存为 30 台,作为预测第 1 个月(t=13)的期初库存;此后各月期初库存为上月末剩余库存。</li>
</ol>
<p class="procurement-plan-formula">月度采购量 = 预测销量 + 安全库存 - 期初库存</p>
</section>
<div class="procurement-plan-table-wrap">
<el-table :data="procurementPlanRows" border class="procurement-plan-table">
<el-table-column prop="label" label="预测月份" align="center" min-width="220" />
<el-table-column label="预测销量(台)" align="center" min-width="160">
<template #default="scope">
<span class="procurement-plan-readonly">{{ procurementPlanExpectedRows[scope.$index]?.demand ?? '请先完成第 1 步' }}</span>
</template>
</el-table-column>
<el-table-column label="安全库存(台)" align="center" min-width="165">
<template #default="scope">
<el-input-number v-model="scope.row.safetyStock" :min="0" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="期初库存(台)" align="center" min-width="165">
<template #default="scope">
<el-input-number v-model="scope.row.openingStock" :min="0" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="采购量(台)" align="center" min-width="165">
<template #default="scope">
<el-input-number v-model="scope.row.purchaseQuantity" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
</el-table>
</div>
<p class="procurement-plan-hint">预测销量由第 1 步同步带入。提交时将按题设规则校验安全库存、期初库存和采购量。</p>
</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: '#1f2937!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>
<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="当前任务建议:先完成未来 3 个月需求预测,再上传包含历史销量与预测值的散点趋势图。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.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, TrendCharts } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validatePurchaseDemandForecastStepOne } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
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 PURCHASE_DEMAND_FORECAST_GUIDE_URL = "/file/training-materials/purchase-demand-forecast/18.实训操作说明.docx";
const taskConfig = ref(null);
const activeStep = ref(1);
const saving = ref(false);
const briefOpen = 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 createLinearForecastRows = () => [
{ time: 13, label: "预测第1个月(t=13)", demand: null },
{ time: 14, label: "预测第2个月(t=14)", demand: null },
{ time: 15, label: "预测第3个月(t=15)", demand: null },
];
const linearForecastRows = ref(createLinearForecastRows());
const PROCUREMENT_INITIAL_STOCK = 30;
const createProcurementPlanRows = () =>
createLinearForecastRows().map(({ time, label }) => ({
time,
label,
safetyStock: null,
openingStock: null,
purchaseQuantity: null,
}));
const procurementPlanRows = ref(createProcurementPlanRows());
const procurementPlanExpectedRows = computed(() => {
let expectedOpeningStock = PROCUREMENT_INITIAL_STOCK;
return linearForecastRows.value.map((forecastRow, index) => {
const demand = Number(forecastRow?.demand);
const hasValidDemand = Number.isInteger(demand) && demand >= 0;
const safetyStock = hasValidDemand ? Math.round(demand * 0.1) : null;
const openingStock = hasValidDemand ? expectedOpeningStock : null;
const purchaseQuantity = hasValidDemand
? demand + safetyStock - openingStock
: null;
if (hasValidDemand) {
expectedOpeningStock = safetyStock;
}
return {
time: forecastRow?.time ?? index + 13,
label: forecastRow?.label || `预测第${index + 1}个月(t=${index + 13})`,
demand: hasValidDemand ? demand : null,
safetyStock,
openingStock,
purchaseQuantity,
};
});
});
const demandForecastChart = ref({ url: "", name: "" });
const uploadingDemandForecastChart = ref(false);
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());
};
function downloadPracticeGuide() {
downloadBuiltInFile(PURCHASE_DEMAND_FORECAST_GUIDE_URL, "18.实训操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function clearDemandForecastChart() {
const imageUrl = String(demandForecastChart.value?.url || "");
if (imageUrl.startsWith("blob:")) URL.revokeObjectURL(imageUrl);
demandForecastChart.value = { url: "", name: "" };
}
async function handleDemandForecastChartUpload(event) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
const acceptedTypes = ["image/png", "image/jpeg", "image/webp"];
if (!acceptedTypes.includes(file.type)) {
proxy?.$modal?.msgWarning("请上传 PNG、JPG、JPEG 或 WEBP 格式的图片");
return;
}
if (file.size > 5 * 1024 * 1024) {
proxy?.$modal?.msgWarning("图片大小不能超过 5MB");
return;
}
uploadingDemandForecastChart.value = true;
try {
let url = "";
if (isStudentDemo()) {
url = URL.createObjectURL(file);
} else {
const formData = new FormData();
formData.append("file", file);
const response = await uploadStudentTrainingFile(formData);
url = response?.url || response?.data?.url || "";
if (!url) throw new Error("上传成功但未返回文件地址");
}
clearDemandForecastChart();
demandForecastChart.value = { url, name: file.name };
proxy?.$modal?.msgSuccess("需求预测可视化图形已上传,可点击缩略图预览");
} catch (error) {
proxy?.$modal?.msgError(error?.message || "上传失败,请稍后重试");
} finally {
uploadingDemandForecastChart.value = false;
}
}
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,
linearForecastRows: linearForecastRows.value,
procurementPlanRows: procurementPlanRows.value,
demandForecastChart: { ...demandForecastChart.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.linearForecastRows)) {
const demandByTime = new Map(value.linearForecastRows.map((item) => [Number(item?.time), item?.demand]));
linearForecastRows.value = createLinearForecastRows().map((row) => ({ ...row, demand: demandByTime.get(row.time) ?? null }));
}
if (Array.isArray(value.procurementPlanRows)) {
const planByTime = new Map(value.procurementPlanRows.map((item) => [Number(item?.time), item]));
procurementPlanRows.value = createProcurementPlanRows().map((row) => {
const savedRow = planByTime.get(row.time) || {};
return {
...row,
safetyStock: savedRow.safetyStock ?? null,
openingStock: savedRow.openingStock ?? null,
purchaseQuantity: savedRow.purchaseQuantity ?? null,
};
});
}
if (value.demandForecastChart && typeof value.demandForecastChart === "object") {
demandForecastChart.value = {
url: String(value.demandForecastChart.url || ""),
name: String(value.demandForecastChart.name || ""),
};
}
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; }
function isInteger(value) {
return Number.isInteger(Number(value));
}
function validateProcurementPlan() {
const expectedRows = procurementPlanExpectedRows.value;
if (expectedRows.some((row) => row.demand === null)) {
proxy?.$modal?.msgWarning("请先完成第 1 步,填写未来 3 个月的预测销量");
return false;
}
for (let index = 0; index < expectedRows.length; index += 1) {
const actualRow = procurementPlanRows.value[index];
const expectedRow = expectedRows[index];
const monthName = `第 ${index + 1} 个月`;
if (
!isInteger(actualRow?.safetyStock)
|| !isInteger(actualRow?.openingStock)
|| !isInteger(actualRow?.purchaseQuantity)
) {
proxy?.$modal?.msgWarning(`请将${monthName}的安全库存、期初库存和采购量填写为整数`);
return false;
}
if (Number(actualRow.safetyStock) !== expectedRow.safetyStock) {
proxy?.$modal?.msgWarning(`${monthName}安全库存计算不正确,应为 ${expectedRow.safetyStock} 台`);
return false;
}
if (Number(actualRow.openingStock) !== expectedRow.openingStock) {
proxy?.$modal?.msgWarning(`${monthName}期初库存不正确,应为 ${expectedRow.openingStock} 台`);
return false;
}
if (Number(actualRow.purchaseQuantity) !== expectedRow.purchaseQuantity) {
proxy?.$modal?.msgWarning(`${monthName}采购量计算不正确,应为 ${expectedRow.purchaseQuantity} 台`);
return false;
}
}
return true;
}
// 提交
const submitTask = async () => {
if (activeStep.value === 1) {
if (saving.value) return;
saving.value = true;
try {
const response = await validatePurchaseDemandForecastStepOne({
items: linearForecastRows.value.map(({ time, demand }) => ({ time, demand })),
});
const normalizedItems = response?.data?.items;
if (Array.isArray(normalizedItems)) {
const demandByTime = new Map(normalizedItems.map((item) => [Number(item.time), item.demand]));
linearForecastRows.value = createLinearForecastRows().map((row) => ({ ...row, demand: demandByTime.get(row.time) ?? null }));
}
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
if (activeStep.value === 2) {
if (!demandForecastChart.value.url) {
proxy?.$modal?.msgWarning("请上传需求预测可视化图形");
return;
}
if (saving.value) return;
saving.value = true;
try {
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
if (activeStep.value === 3) {
if (!validateProcurementPlan() || saving.value) return;
saving.value = true;
try {
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
// 获取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 {
linearForecastRows.value = createLinearForecastRows();
procurementPlanRows.value = createProcurementPlanRows();
clearDemandForecastChart();
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 {
/* 主色:教育蓝 */
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
/* 语义色 */
--student-danger: #ef4444;
--student-success: #2ba471;
/* 三层次标识 */
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
/* 结构色 */
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
/* Element Plus 主色 */
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
/* 紧凑字号梯度 */
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: var(--student-card-bg);
:deep(.el-table) {
--el-table-border-color: var(--student-panel-border);
--el-table-header-bg-color: #fafbfc;
--el-table-header-text-color: var(--student-text);
--el-table-row-hover-bg-color: rgba(59, 130, 246, 0.05);
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-text-color: var(--student-text);
overflow: hidden;
margin-top: 12px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
}
:deep(.el-table th.el-table__cell) {
color: var(--student-text) !important;
background: #fafbfc !important;
font-weight: 800;
}
:deep(.el-input__wrapper) {
min-height: 36px;
border: 1px solid var(--student-panel-border);
border-radius: 7px;
box-shadow: none !important;
background: var(--student-card-bg);
}
:deep(.el-input__inner) {
color: var(--student-text);
}
:deep(.el-input__inner::placeholder) {
color: var(--student-text-secondary);
}
:deep(.el-input__wrapper.is-focus) {
border-color: var(--student-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12) !important;
}
:deep(.el-button:not(.material-button)) {
border-color: var(--student-accent) !important;
color: #ffffff !important;
background: var(--student-accent) !important;
}
:deep(.el-button:not(.material-button):hover) {
border-color: var(--student-accent-deep) !important;
color: #ffffff !important;
background: var(--student-accent-deep) !important;
}
.online {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: var(--student-panel-bg);
}
.online span {
color: var(--student-text);
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
.channel {
display: grid; gap: 24px; min-height: calc(100vh - 76px); padding: 24px 24px 32px; color: var(--student-text); background: var(--student-card-bg);
.training-core { min-width: 0; padding: 28px; border: 1px solid var(--student-panel-border); border-radius: 36px; background: var(--student-card-bg); box-shadow: 0 6px 18px rgba(0,0,0,.06); }
.task-header { position: relative; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin: 0; padding: 18px 20px 18px 24px; border: 1px solid var(--student-panel-border); border-radius: 12px; background: var(--student-card-bg); overflow: hidden; &::before { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 4px; background: var(--student-accent); } }
.task-head-main { display: grid; justify-items: start; gap: 8px; min-width: 0; flex: 1 1 auto; }
.task-title { margin: 0; color: var(--student-text); font-size: var(--student-fs-display); font-weight: 700; line-height: 1.35; }
.task-brief { margin: 0; color: var(--student-text-secondary); font-size: 12px; line-height: 1.6; }
.task-brief-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 1; }
.task-brief-toggle { padding: 0; border: 0; color: var(--student-accent-deep); background: transparent; font: inherit; font-weight: 700; cursor: pointer; &:hover { text-decoration: underline; } }
.task-brief-more { display: grid; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--student-panel-border); b { color: var(--student-text); font-size: 12px; } ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; } p { margin: 0; } }
.task-card,.step-card { border: 1px solid var(--student-panel-border); border-radius: 26px; background: var(--student-card-bg); }.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 var(--student-panel-border); }.section-eyebrow { margin: 0; color: var(--student-accent); font-size: var(--student-fs-caption); font-weight: 800; line-height: 1.4; }.workbench-head h3 { margin: 4px 0 8px; color: var(--student-text); font-size: var(--student-fs-h1); font-weight: 800; line-height: 1.3; }.workbench-description { margin: 0; color: var(--student-text-secondary); font-size: var(--student-fs-body); 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: 36px; height: 36px; padding: 0 14px; border: 1px solid var(--student-panel-border); border-radius: 8px; color: var(--student-text); background: var(--student-accent-soft); font: inherit; font-size: var(--student-fs-button); 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 var(--student-panel-border); border-radius:18px; background:var(--student-card-bg); box-shadow:inset 0 1px 0 rgba(31, 41, 55, 0.04); }.step-tab { display:flex; flex:1 0 145px; align-items:center; min-height:66px; padding:8px 10px; border:1px solid var(--student-panel-border); border-radius:8px; color:var(--student-text-secondary); background:var(--student-card-bg); font:inherit; font-size:var(--student-fs-body); 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:var(--student-accent); color:#ffffff; background:var(--student-accent-light); box-shadow:0 8px 18px rgba(0,0,0,.1); outline:none; }.step-tab.active { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 8px 20px rgba(31, 41, 55, 0.18); }.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid var(--student-panel-border); border-radius:50%; color:var(--student-text-secondary); background:var(--student-card-bg); font-size:var(--student-fs-body-sm); 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:var(--student-fs-body); line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.step-copy span { color:var(--student-text-secondary); font-size:var(--student-fs-caption); font-weight:700; }.step-tab.active .step-marker { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 0 0 4px rgba(31, 41, 55, 0.1); }.step-tab.complete .step-marker { border-color:var(--student-success); color:#ffffff; background:var(--student-success); }.step-connector { flex:0 0 28px; height:2px; background:var(--student-panel-border); color:var(--student-text-secondary); font-size:var(--student-fs-h2); line-height:0; text-align:right; }.step-connector.complete { background:var(--student-success); color:var(--student-success); }.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: var(--student-text); font-size: var(--student-fs-h2); font-weight: 900; line-height: 1.3; }.caseBacktitle { display: flex; align-items: center; gap: 8px; margin: 20px 0 10px; color: var(--student-text-secondary); font-size: var(--student-fs-h2); font-weight: 800; }.caseBacktitle img { display: none; }.step-card > p { color: var(--student-text-secondary); font-size: var(--student-fs-body); line-height: 1.8; }
:deep(.el-table) { width: 100% !important; max-width: 100%; border-radius: 18px; }:deep(.el-table th.el-table__cell) { background: #fafbfc !important; }:deep(.el-input__wrapper) { min-height: 42px; border-radius: 14px; background: #ffffff; }.online { border-radius: 14px; background: var(--student-panel-bg); }.step-navigation { display: flex; justify-content: center; gap: 14px; margin-top: 22px; }.btn-nav.btn-save,.btn-nav.btn-primary { border-color: var(--student-accent); color: #ffffff; background: var(--student-accent); min-height: 36px; height: 36px; padding: 0 14px; border-radius: 8px; }.btn-nav:disabled { cursor: not-allowed; opacity: .45; }
}
@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 { 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; } } }
.step-tab.active { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 0 14px rgba(31, 41, 55, 0.12); }.step-tab.active .step-marker { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 0 0 5px rgba(31, 41, 55, 0.1); }.step-tab.complete { color:var(--student-text-secondary); }.step-tab.complete .step-marker { border-color:var(--student-success); color:#ffffff; background:var(--student-success); }.step-connector.complete { background:var(--student-success); color:var(--student-success); }
/* 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 var(--student-panel-border); border-radius:18px; background:var(--student-card-bg); box-shadow:inset 0 1px 0 rgba(31, 41, 55, 0.04); 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 var(--student-panel-border); border-radius:8px; color:var(--student-text-secondary); background:var(--student-card-bg); box-shadow:inset 0 1px 0 rgba(31, 41, 55, 0.03); font:inherit; font-size:var(--student-fs-body); 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:var(--student-accent); color:#ffffff; background:var(--student-accent-light); box-shadow:0 7px 18px rgba(0,0,0,.1); }
.step-tab:hover .step-action { color:#ffffff; }
.step-tab:focus-visible { outline:2px solid var(--student-accent); outline-offset:2px; }
.step-tab.active { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 0 14px rgba(31, 41, 55, 0.12); }
.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid var(--student-panel-border); border-radius:50%; color:var(--student-text-secondary); background:var(--student-accent-soft); box-shadow:inset 0 0 12px rgba(31, 41, 55, 0.04); font-size:var(--student-fs-caption); 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:var(--student-fs-body); font-weight:800; line-height:1.25; text-overflow:ellipsis; -webkit-box-orient:vertical; -webkit-line-clamp:2; }
.step-action { color:var(--student-text-secondary); font-size:var(--student-fs-caption); font-weight:800; letter-spacing:.06em; line-height:1; }
.step-tab.active .step-marker { border-color:var(--student-accent); color:#ffffff; background:var(--student-accent); box-shadow:0 0 0 5px rgba(31, 41, 55, 0.1); }
.step-tab.complete .step-marker { border-color:var(--student-success); color:#ffffff; background:var(--student-success); }
.step-connector { position:relative; flex:0 1 40px; min-width:18px; height:2px; margin:0 8px; overflow:visible; background:var(--student-panel-border); }
.step-connector::after { position:absolute; top:50%; right:-1px; width:6px; height:6px; border-top:1px solid var(--student-text-secondary); border-right:1px solid var(--student-text-secondary); content:""; transform:translateY(-50%) rotate(45deg); }
.step-tab.complete + .step-connector { background:var(--student-success); }
.step-tab.complete + .step-connector::after { border-color:var(--student-success); }
@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;
}
.linear-forecast-intro {
margin: 0 0 22px;
color: var(--student-text);
font-size: var(--student-fs-h2);
line-height: 1.85;
}
.linear-forecast-panel {
overflow: hidden;
border-radius: 16px;
}
.linear-forecast-table {
min-width: 640px;
margin-top: 0 !important;
}
.linear-forecast-input {
width: min(100%, 320px);
}
.linear-forecast-guide {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
padding: 18px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: var(--student-panel-bg);
}
.linear-forecast-guide ol {
margin: 8px 0 0;
padding-left: 22px;
color: var(--student-text-secondary);
line-height: 1.8;
}
.procurement-plan-intro {
margin: 0 0 18px;
color: var(--student-text);
font-size: var(--student-fs-h2);
line-height: 1.85;
}
.procurement-plan-rules {
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: var(--student-panel-bg);
}
.procurement-plan-rules h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
}
.procurement-plan-rules ol {
margin: 10px 0 8px;
padding-left: 22px;
color: var(--student-text-secondary);
line-height: 1.8;
}
.procurement-plan-formula {
margin: 0;
color: var(--student-accent) !important;
font-weight: 800;
}
.procurement-plan-table-wrap {
overflow-x: auto;
border-radius: 16px;
}
.procurement-plan-table {
min-width: 960px;
margin-top: 0 !important;
}
.procurement-plan-readonly {
color: var(--student-accent);
font-weight: 800;
}
.procurement-plan-input {
width: min(100%, 160px);
}
.procurement-plan-hint {
margin: 14px 0 0;
color: var(--student-text-secondary) !important;
font-size: var(--student-fs-body) !important;
line-height: 1.7;
}
.guide-title {
margin: 0;
color: var(--student-text) !important;
font-weight: 800;
}
.practice-guide-button {
flex: 0 0 auto;
}
.forecast-visual-intro {
margin: 0 0 22px;
color: var(--student-text);
font-size: var(--student-fs-h2);
line-height: 1.85;
}
.forecast-visual-upload-card {
display: grid;
min-height: 330px;
padding: 28px;
border: 1px solid var(--student-panel-border);
border-radius: 16px;
background: var(--student-card-bg);
place-items: center;
}
.forecast-visual-dropzone {
position: relative;
display: grid;
width: min(100%, 620px);
min-height: 210px;
padding: 28px;
overflow: hidden;
border: 1px dashed var(--student-panel-border);
border-radius: 14px;
color: var(--student-text);
background: var(--student-accent-soft);
cursor: pointer;
place-items: center;
gap: 10px;
text-align: center;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.forecast-visual-dropzone:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.forecast-visual-dropzone.is-uploading {
cursor: wait;
opacity: 0.72;
}
.forecast-visual-dropzone input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.forecast-visual-dropzone strong {
font-size: var(--student-fs-h2);
}
.forecast-visual-dropzone span:last-child {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.forecast-visual-upload-icon {
color: var(--student-accent);
font-size: 34px;
font-weight: 900;
line-height: 1;
}
.forecast-visual-preview {
display: flex;
align-items: center;
width: min(100%, 760px);
min-height: 210px;
gap: 24px;
padding: 18px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: #ffffff;
}
.forecast-visual-preview :deep(.el-image) {
flex: 0 0 330px;
width: 330px;
height: 190px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: var(--student-panel-bg);
cursor: zoom-in;
}
.forecast-visual-file-meta {
display: flex;
flex-direction: column;
min-width: 0;
gap: 10px;
}
.forecast-visual-file-meta strong {
color: var(--student-text);
line-height: 1.6;
word-break: break-all;
}
.forecast-visual-file-meta span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.forecast-visual-replace {
align-self: flex-start;
padding: 7px 13px;
border: 1px solid var(--student-panel-border);
border-radius: 7px;
color: var(--student-text);
background: var(--student-accent-soft);
cursor: pointer;
font: inherit;
font-size: var(--student-fs-body-sm);
font-weight: 700;
}
.forecast-visual-replace:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.forecast-visual-guide {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
padding: 18px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: var(--student-panel-bg);
}
.forecast-visual-guide ol {
margin: 8px 0 0;
padding-left: 22px;
color: var(--student-text-secondary);
line-height: 1.8;
}
@media (max-width: 900px) {
.linear-forecast-guide {
align-items: stretch;
flex-direction: column;
}
.procurement-plan-rules {
padding: 14px 16px;
}
.forecast-visual-preview,
.forecast-visual-guide {
align-items: stretch;
flex-direction: column;
}
.forecast-visual-preview :deep(.el-image) {
flex-basis: auto;
width: 100%;
height: 190px;
}
.practice-guide-button {
width: 100%;
}
}
</style>
<style lang="scss">
/* ============================================================
* 教育蓝极简换肤 · channel/index.vue
* ============================================================ */
/* 画布背景 */
#app .studentLayout .app-main:has(.channel) {
background: #ffffff !important;
}
/* el-table 基础换肤 */
:has(.channel) {
.el-table {
--el-table-bg-color: #ffffff !important;
--el-table-tr-bg-color: transparent !important;
--el-table-header-bg-color: #fafbfc !important;
--el-table-row-hover-bg-color: rgba(37, 99, 235, 0.04) !important;
line-height: 1.55;
}
}
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝极简)
* ============================================================ */
#app .studentLayout .app-main:has(.channel) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-tab {
flex: 1 1 0 !important;
max-width: 240px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: normal !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-bar {
display: none !important;
}
.step-no,
.step-marker {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
.step-copy {
margin-left: 0 !important;
gap: 4px !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no,
.step-marker {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no,
.step-marker {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no,
.step-marker {
background: #2ba471 !important;
color: #ffffff !important;
}
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-no,
.step-marker {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
.step-connector {
display: none !important;
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮
* ============================================================ */
.channel .workbench-actions .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ============================================================
* 顶部导出按钮(outline-action)
* ============================================================ */
.channel .workbench-actions > button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1 !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: none !important;
}
}
/* ============================================================
* 底部步骤按钮(task-actions > button)
* ============================================================ */
.channel .task-actions {
gap: 14px !important;
margin-top: 20px !important;
}
.channel .task-actions > button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1 !important;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease !important;
}
.channel .task-actions > button:hover:not(:disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
/* 主按钮(btn-primary / btn-submit) */
.channel .task-actions > button.btn-primary,
.channel .task-actions > button.btn-submit {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
.channel .task-actions > button.btn-primary:hover:not(:disabled),
.channel .task-actions > button.btn-submit:hover:not(:disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
/* ============================================================
* 步骤导航按钮(step-navigation)
* ============================================================ */
.channel .step-navigation {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
gap: 14px !important;
margin-top: 20px !important;
}
.channel .step-navigation .btn-nav {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease !important;
}
.channel .step-navigation .btn-nav:hover:not(:disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
.channel .step-navigation .btn-nav.btn-primary {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
.channel .step-navigation .btn-nav.btn-primary:hover:not(:disabled) {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
.channel .step-navigation .btn-nav.btn-save {
border: 1px solid #e5e7eb !important;
background: #ffffff !important;
color: #6b7280 !important;
}
/* ============================================================
* 顶部任务工具栏(el-button)
* ============================================================ */
.channel .task-toolbar-button.el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 12px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
.channel .task-toolbar-button--submit {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
box-shadow:
0 1px 2px rgba(37, 99, 235, 0.35),
0 3px 10px rgba(37, 99, 235, 0.22) !important;
&:hover:not(.is-disabled) {
background: #1d4ed8 !important;
box-shadow:
0 2px 4px rgba(37, 99, 235, 0.4),
0 5px 14px rgba(29, 78, 216, 0.28) !important;
}
}
/* ============================================================
* 窄屏:横向滑动
* ============================================================ */
@media (max-width: 900px) {
#app .studentLayout .app-main:has(.channel) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout .app-main:has(.channel) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
</style>