|
|
<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></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>
|
|
|
<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>
|
|
|
<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">
|
|
|
<div class="step-nav-left">
|
|
|
<button v-if="activeStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
|
|
|
@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>
|
|
|
</div>
|
|
|
<div class="step-nav-right">
|
|
|
<span class="step-progress">Step {{ String(activeStep).padStart(2, "0") }} / {{
|
|
|
String(configuredSteps.length).padStart(2, "0") }}</span>
|
|
|
<button v-if="activeStep < configuredSteps.length" type="button" class="btn-nav btn-primary"
|
|
|
:disabled="saving" @click="goNextStep">
|
|
|
下一步
|
|
|
<el-icon>
|
|
|
<ArrowRight />
|
|
|
</el-icon>
|
|
|
</button>
|
|
|
</div>
|
|
|
</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(true)
|
|
|
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 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-training-shell,页面不再重复声明 */
|
|
|
|
|
|
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: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.task-brief-text {
|
|
|
display: block;
|
|
|
}
|
|
|
|
|
|
.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: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
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 {
|
|
|
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 {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
gap: 10px;
|
|
|
margin-top: 16px;
|
|
|
padding: 12px 16px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
flex-wrap: nowrap;
|
|
|
}
|
|
|
|
|
|
.step-nav-right {
|
|
|
margin-left: auto;
|
|
|
}
|
|
|
|
|
|
.btn-primary {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid var(--student-accent);
|
|
|
border-radius: 8px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent-deep);
|
|
|
background: var(--student-accent-deep);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
|
|
|
}
|
|
|
|
|
|
&:active:not(:disabled) {
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-ghost {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: var(--student-accent);
|
|
|
background: #ffffff;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-save {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
border-radius: 8px;
|
|
|
color: #4b5563;
|
|
|
background: #f9fafb;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: #d1d5db;
|
|
|
background: #f3f4f6;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-primary:disabled,
|
|
|
.btn-ghost:disabled,
|
|
|
.btn-save:disabled,
|
|
|
.btn-nav:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.step-progress {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-family: var(--student-font-data);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.05em;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@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-nav-left,
|
|
|
.step-nav-right {
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
|
.procurement-plan-rules {
|
|
|
padding: 14px 16px;
|
|
|
}
|
|
|
|
|
|
.forecast-visual-preview,
|
|
|
|
|
|
.forecast-visual-preview :deep(.el-image) {
|
|
|
flex-basis: auto;
|
|
|
width: 100%;
|
|
|
height: 190px;
|
|
|
}
|
|
|
|
|
|
}
|
|
|
</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 {
|
|
|
|
|
|
|
|
|
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: var(--student-fs-body) !important;
|
|
|
font-weight: 600 !important;
|
|
|
line-height: 1.3 !important;
|
|
|
white-space: nowrap !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: var(--student-fs-body-sm) !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: var(--student-fs-body) !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: var(--student-fs-body) !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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 顶部任务工具栏(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: var(--student-fs-body) !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>
|