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/product/after-sales-service.vue

1677 lines
64 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="after-sales-page student-training-shell">
<main class="after-sales-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="validateAndSaveStepThree"
:on-export="exportOutcome" />
</section>
<section class="after-sales-workbench">
<nav class="step-indicator" aria-label="售后服务流程设计步骤">
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
<button type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
@click="switchStep(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">{{ currentStep === index + 1 ? '当前步骤' : '点击切换' }}
→</span></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section v-if="currentStep === 1" class="step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 01</p>
<h3>{{ trainingSteps[0] }}</h3>
</div>
<span class="step-chip">拖拽分类判题</span>
</div>
<p class="step-desc">互联网产品的售后服务与传统产品有显著区别。传统产品售后主要集中在硬件故障维修,而互联网产品特别是智能化的产品,还需要处理联网问题、云端连接故障等软件层面的售后需求,且可以通过远程诊断、OTA升级等方式降低售后成本。故障类型分类是售后服务流程设计的第一步,产品团队收集了售后典型场景,需要进行分类。请将右侧被打乱的故障现象拖入对应的故障类型;完成全部分类并判题正确后才能进入下一步。
</p>
<div class="classification-workspace">
<section class="fault-lanes" aria-label="故障类型分类区">
<article v-for="category in CATEGORIES" :key="category.id" class="fault-lane"
:class="{ 'fault-lane--over': dragOverCategory === category.id }"
@dragover.prevent="dragOverCategory = category.id" @dragleave="dragOverCategory = ''"
@drop.prevent="dropIntoCategory(category.id)">
<header class="lane-head">
<div><span class="lane-index">{{ category.index }}</span>
<h4>{{ category.name }}</h4>
</div>
<span>{{ faultsByCategory(category.id).length }}/2</span>
</header>
<p>{{ category.description }}</p>
<div class="lane-drop-zone">
<article v-for="fault in faultsByCategory(category.id)" :key="fault.id"
class="fault-card" :class="{ dragging: dragFaultId === fault.id }"
:draggable="!checking" @dragstart="onDragStart(fault.id)" @dragend="onDragEnd">
<span class="fault-handle" title="拖拽分类">⠿</span>
<p>{{ fault.text }}</p>
<label class="fault-select-label">
<span>分类</span>
<el-select :model-value="assignments[fault.id] || ''" :disabled="checking"
size="small" class="fault-select"
@update:model-value="(val) => setCategory(fault.id, val)">
<el-option value="" label="待分类" />
<el-option v-for="cat in CATEGORIES" :key="cat.id" :value="cat.id"
:label="cat.name" />
</el-select>
</label>
</article>
<span v-if="!faultsByCategory(category.id).length"
class="drop-hint">将故障现象拖到这里</span>
</div>
</article>
</section>
<aside class="unassigned-panel"
:class="{ 'unassigned-panel--over': dragOverCategory === 'unassigned' }"
@dragover.prevent="dragOverCategory = 'unassigned'" @dragleave="dragOverCategory = ''"
@drop.prevent="dropIntoCategory('')">
<div class="unassigned-head"><span class="section-eyebrow">待分类故障现象</span><strong>{{
unassignedFaults.length
}}</strong></div>
<p>卡片初始顺序已打乱。可直接拖拽,也可用卡片内的下拉框辅助调整。</p>
<div class="unassigned-list">
<article v-for="fault in unassignedFaults" :key="fault.id" class="fault-card"
:class="{ dragging: dragFaultId === fault.id }" :draggable="!checking"
@dragstart="onDragStart(fault.id)" @dragend="onDragEnd">
<span class="fault-handle" title="拖拽分类">⠿</span>
<p>{{ fault.text }}</p>
<label class="fault-select-label">
<span>分类</span>
<el-select :model-value="assignments[fault.id] || ''" :disabled="checking"
size="small" class="fault-select"
@update:model-value="(val) => setCategory(fault.id, val)">
<el-option value="" label="待分类" />
<el-option v-for="cat in CATEGORIES" :key="cat.id" :value="cat.id"
:label="cat.name" />
</el-select>
</label>
</article>
<span v-if="!unassignedFaults.length" class="unassigned-empty">全部故障现象已完成归类</span>
</div>
</aside>
</div>
<p v-if="checkResult" class="result-message" :class="{ success: checkResult.allCorrect }">{{
checkResult.message ||
(checkResult.allCorrect ? '分类正确,已解锁下一步。' : `当前
${checkResult.correctCount}/${checkResult.totalCount}
个分类正确,请继续调整。`) }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="checking" @click="resetFaults"><el-icon>
<RefreshLeft />
</el-icon>重新打乱</button>
</div>
</section>
<section v-else-if="currentStep === 2" class="step-card service-design-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 02</p>
<h3>{{ trainingSteps[1] }}</h3>
</div>
<span class="step-chip">方案设计与规则沉淀</span>
</div>
<p class="step-desc">根据 4
类故障类型,设计标准化的分级处理流程。请结合案例资料中的售后政策和常见故障案例,完成处理流程表。</p>
<div class="service-table-wrap">
<table class="service-table">
<thead>
<tr>
<th>故障类型</th>
<th>具体场景</th>
<th>处理流程</th>
<th>核心规则</th>
</tr>
</thead>
<tbody>
<tr v-for="row in SERVICE_ROWS" :key="row.id">
<th scope="row"><span class="service-row-index">{{ row.index }}</span>{{ row.type }}
</th>
<td><em>{{ row.scene }}</em></td>
<td>
<textarea v-model="serviceAnswers[row.id].process"
:aria-label="`${row.type}处理流程`" placeholder="请填写处理流程"
@input="clearStepTwoGrade" />
<button v-if="row.processExample" type="button" class="example-button"
@click="applyExample(row.id)">带入示例</button>
<em v-if="row.processExample" class="example-copy">示例:{{ row.processExample
}}</em>
</td>
<td>
<textarea v-model="serviceAnswers[row.id].rule" :aria-label="`${row.type}核心规则`"
placeholder="请填写核心规则" @input="clearStepTwoGrade" />
<em v-if="row.ruleExample" class="example-copy">示例:{{ row.ruleExample }}</em>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="stepTwoResult" class="result-message" :class="{ success: stepTwoResult.valid }">{{
stepTwoResult.message }}
</p>
</section>
<section v-else-if="currentStep === 3" class="step-card policy-design-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 03</p>
<h3>{{ trainingSteps[2] }}</h3>
</div>
<span class="step-chip">消费者权益与售后承诺</span>
</div>
<p class="step-desc">根据步骤二的分级处理流程设计,制定面向消费者的完整售后政策。售后政策是消费者权益的保障承诺,需覆盖退换货、质保、维修服务、响应时效、补偿方案等核心维度,可直接用于产品说明书附录及电商平台详情页“售后保障”模块。</p>
<section class="policy-section" aria-labelledby="policy-framework-title">
<div class="section-heading"><span>01</span>
<div>
<h3 id="policy-framework-title">填写售后政策框架表</h3>
<p>请结合实训资料中的产品信息、行业法规(如《消费者权益保护法》《网络购买商品七日无理由退货暂行办法》)及竞品售后政策参考,完成以下售后政策框架表。</p>
</div>
</div>
<div class="service-table-wrap">
<table class="service-table policy-table">
<thead>
<tr>
<th>政策维度</th>
<th>政策内容</th>
<th>适用条件与说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in POLICY_ROWS" :key="row.id">
<th scope="row"><span class="service-row-index">{{ row.index }}</span>{{
row.dimension }}</th>
<td>
<textarea v-model="policyAnswers.framework[row.id].content"
:aria-label="`${row.dimension}政策内容`" placeholder="请填写政策内容"
@input="clearStepThreeGrade" />
<button v-if="row.contentExample" type="button" class="example-button"
@click="applyPolicyExample(row.id)">带入示例</button>
<em v-if="row.contentExample" class="example-copy">示例:{{ row.contentExample
}}</em>
</td>
<td>
<textarea v-model="policyAnswers.framework[row.id].condition"
:aria-label="`${row.dimension}适用条件与说明`" placeholder="请填写适用条件与说明"
@input="clearStepThreeGrade" />
<em v-if="row.conditionExample" class="example-copy">示例:{{
row.conditionExample }}</em>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="policy-section" aria-labelledby="policy-summary-title">
<div class="section-heading"><span>02</span>
<div>
<h3 id="policy-summary-title">填写电商详情页售后信息摘要</h3>
<p>将售后政策框架表中的核心信息提炼为消费者友好的简明摘要,用于电商平台详情页“售后保障”模块展示。要求语言简洁、重点突出,消费者3秒内可获取关键售后信息。</p>
</div>
</div>
<div class="service-table-wrap">
<table class="service-table policy-summary-table">
<thead>
<tr>
<th>售后信息项</th>
<th>详情页展示内容</th>
</tr>
</thead>
<tbody>
<tr v-for="row in POLICY_SUMMARY_ROWS" :key="row.id">
<th scope="row">{{ row.label }}</th>
<td>
<textarea v-model="policyAnswers.summary[row.id]"
:aria-label="`${row.label}详情页展示内容`" placeholder="请填写面向消费者的简明承诺"
@input="clearStepThreeGrade" />
<button v-if="row.example" type="button" class="example-button"
@click="applySummaryExample(row.id)">带入示例</button>
<em v-if="row.example" class="example-copy">示例:{{ row.example }}</em>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-if="stepThreeResult" class="result-message" :class="{ success: stepThreeResult.valid }">{{
stepThreeResult.message }}</p>
</section>
<section v-else class="step-card placeholder-card">
<p class="section-eyebrow">Step {{ String(currentStep).padStart(2, '0') }}</p>
<h3>{{ trainingSteps[currentStep - 1] }}</h3>
<p>第 1 步已通过,后续步骤将在对应实训页面继续完成。</p>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
@click="previousStep">
<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(currentStep).padStart(2, "0") }} / {{
String(trainingSteps.length).padStart(2, "0") }}</span>
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary"
:disabled="saving" @click="nextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="先根据故障现象定位排查层级:本地硬件、配网操作、云端连接或需要升级处理的严重故障。" reference-title="故障分类提示"
reference-text="· 本地问题优先确认设备本体与基础控制<br />· 配网问题聚焦 App、设备发现与连接过程<br />· 云端问题关注远程控制稳定性与掉线<br />· 严重故障需要优先停止使用并升级处理"
:progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck, RefreshLeft, Service } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { checkAfterSalesServiceStepOne, checkAfterSalesServiceStepTwo, checkAfterSalesServiceStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { getStudentTrainingPage } from "@/views/training/studentTrainingPages"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
const TASK_KEY = "after-sales-service"
const { proxy } = getCurrentInstance()
const basePage = getStudentTrainingPage(TASK_KEY)
const taskConfig = ref(null)
const briefOpen = ref(true)
const currentStep = ref(1)
const checking = ref(false)
const dragFaultId = ref("")
const dragOverCategory = ref("")
const assignments = ref({})
const stepOnePassed = ref(false)
const checkResult = ref(null)
const savingStepTwo = ref(false)
const stepTwoPassed = ref(false)
const stepTwoResult = ref(null)
const savingStepThree = ref(false)
const stepThreePassed = ref(false)
const stepThreeResult = ref(null)
const saving = computed(() => checking.value || savingStepTwo.value || savingStepThree.value)
const CATEGORIES = [
{ id: "local-hardware-simple", index: "01", name: "本地硬件简单故障", description: "设备本体或基础控制环节的常规问题" },
{ id: "operation-network", index: "02", name: "操作/配网类问题", description: "设备发现、添加和连接配置过程的问题" },
{ id: "cloud-connection", index: "03", name: "云端连接类故障", description: "远程控制、云端通信与在线稳定性问题" },
{ id: "severe-fault", index: "04", name: "严重故障", description: "存在安全风险或需升级处理的设备故障" },
]
const FAULTS = [
{ id: "voice-wake-no-response", text: "用户使用语音唤醒,设备无反应,但手动开关灯正常" },
{ id: "voice-control-cannot-switch", text: "唤醒词识别正常,但语音控制设备开机和关机无法实现" },
{ id: "app-cannot-find-device", text: "配网时 APP 搜索不到设备" },
{ id: "network-setup-timeout", text: "APP 添加设备时提示「配网超时」" },
{ id: "remote-control-intermittent", text: "开关正常,但 APP 远程控制时好时坏" },
{ id: "frequent-disconnection", text: "配网成功但每隔几小时就断连" },
{ id: "indicator-no-response", text: "插电后指示灯不亮,完全无反应" },
{ id: "water-damage-no-response", text: "产品进水后通电无反应" },
]
const SERVICE_ROWS = [
{
id: "operation-network", index: "01", type: "操作/配网类问题", scene: "配网失败、APP 找不到设备、唤醒后连不上网",
processExample: "① 发送《一步配网教程》视频\n② 客服远程指导重置配网\n③ 若 30 分钟内未解决,转技术支持",
ruleExample: "远程解决为主,不产生物流成本",
},
{
id: "cloud-connection", index: "02", type: "云端连接类故障", scene: "配网成功但经常断连、云端识别超时、APP 远程控制失效",
processExample: "① 收集设备日志与网络环境\n② 技术支持排查云端服务与账号状态\n③ 修复后回访确认远程控制恢复",
ruleExample: "优先远程诊断;确认平台侧问题后统一告知处理进度",
},
{
id: "local-hardware-simple", index: "03", type: "本地硬件简单故障", scene: "语音识别正常但无法控制相关功能(质保内)",
processExample: "① 远程确认功能设置与固件版本\n② 指导恢复默认设置或 OTA 升级\n③ 无法恢复时登记检测/换新申请",
ruleExample: "质保期内优先远程修复,符合条件后再安排售后服务",
},
{
id: "severe-fault", index: "04", type: "严重故障", scene: "通电无反应、模组烧坏、进水损坏(质保内)",
processExample: "① 提示立即停止使用并收集故障凭证\n② 售后审核安全风险与质保资格\n③ 安排寄修或换新,并跟踪闭环",
ruleExample: "安全风险优先;质保内符合条件可寄修或换新",
},
{
id: "out-of-warranty", index: "05", type: "超出质保期", scene: "任何故障",
processExample: "① 明确告知超出质保范围\n② 提供付费维修、配件购买或以旧换新方案\n③ 用户确认报价后执行服务",
ruleExample: "收费项目与报价须在服务前确认,避免产生争议",
},
]
const POLICY_ROWS = [
{
id: "return-policy", index: "01", dimension: "退换货政策",
contentExample: "7天无理由退货,15天质量问题免费换新",
conditionExample: "商品完好、配件齐全、包装完整;退款至原支付账户,3-5个工作日到账",
},
{ id: "warranty-policy", index: "02", dimension: "质保政策" },
{ id: "repair-service", index: "03", dimension: "维修服务" },
{ id: "response-sla", index: "04", dimension: "响应时效" },
{ id: "compensation-plan", index: "05", dimension: "补偿方案" },
]
const POLICY_SUMMARY_ROWS = [
{ id: "warranty-period", label: "质保期", example: "整机12个月质保,灯珠24个月质保,7天无理由退货" },
{ id: "response-commitment", label: "响应承诺" },
{ id: "featured-service", label: "特色服务" },
]
const fallbackSteps = ["故障类型分类", "服务分级与处理方案", "售后服务流程设计"]
const trainingSteps = computed(() => {
const configuredSteps = parseTrainingArray(taskConfig.value?.steps, fallbackSteps)
return fallbackSteps.map((fallbackStep, index) => configuredSteps[index] || fallbackStep)
})
const taskTitle = computed(() => taskConfig.value?.taskName || basePage.title || "售后服务流程设计")
const taskBackground = computed(() => taskConfig.value?.background || basePage.background)
const taskRequirement = computed(() => taskConfig.value?.requirements || basePage.task)
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, basePage.goals))
const orderedFaults = ref(shuffleFaults())
const serviceAnswers = ref(createEmptyServiceAnswers())
const policyAnswers = ref(createEmptyPolicyAnswers())
const unassignedFaults = computed(() => orderedFaults.value.filter((fault) => !assignments.value[fault.id]))
const progressItems = computed(() => trainingSteps.value.map((text, index) => ({ text, status: index + 1 < currentStep.value ? "done" : index + 1 === currentStep.value ? "doing" : "" })))
onMounted(async () => { await loadTaskConfig(); await loadSavedProgress() })
function shuffleFaults() { return [...FAULTS].sort(() => Math.random() - 0.5) }
function normalizeStep(value) { return Math.min(Math.max(Number(value) || 1, 1), Math.max(trainingSteps.value.length, 1)) }
function faultsByCategory(categoryId) { return orderedFaults.value.filter((fault) => assignments.value[fault.id] === categoryId) }
function onDragStart(faultId) { if (!checking.value) dragFaultId.value = faultId }
function onDragEnd() { dragFaultId.value = ""; dragOverCategory.value = "" }
function setCategory(faultId, categoryId) { assignments.value = { ...assignments.value, [faultId]: categoryId || undefined }; clearGrade() }
function dropIntoCategory(categoryId) { if (!dragFaultId.value || checking.value) return; setCategory(dragFaultId.value, categoryId); onDragEnd() }
function clearGrade() { stepOnePassed.value = false; checkResult.value = null }
function resetFaults() { orderedFaults.value = shuffleFaults(); assignments.value = {}; clearGrade() }
function createEmptyServiceAnswers() { return Object.fromEntries(SERVICE_ROWS.map((row) => [row.id, { process: "", rule: "" }])) }
function createEmptyPolicyAnswers() {
return {
framework: Object.fromEntries(POLICY_ROWS.map((row) => [row.id, { content: "", condition: "" }])),
summary: Object.fromEntries(POLICY_SUMMARY_ROWS.map((row) => [row.id, ""])),
}
}
function buildStepOneAnswer() { return { assignments: assignments.value, orderedFaultIds: orderedFaults.value.map((fault) => fault.id), passed: stepOnePassed.value, checkResult: checkResult.value, updatedAt: new Date().toISOString() } }
function buildStepTwoAnswer() { return { rows: SERVICE_ROWS.map((row) => ({ id: row.id, process: serviceAnswers.value[row.id]?.process || "", rule: serviceAnswers.value[row.id]?.rule || "" })), passed: stepTwoPassed.value, validationResult: stepTwoResult.value, updatedAt: new Date().toISOString() } }
function buildStepThreeAnswer() {
return {
policies: POLICY_ROWS.map((row) => ({ id: row.id, content: policyAnswers.value.framework[row.id]?.content || "", condition: policyAnswers.value.framework[row.id]?.condition || "" })),
summaries: POLICY_SUMMARY_ROWS.map((row) => ({ id: row.id, content: policyAnswers.value.summary[row.id] || "" })),
passed: stepThreePassed.value,
validationResult: stepThreeResult.value,
updatedAt: new Date().toISOString(),
}
}
async function persist(saveAction = "SAVE") { await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: currentStep.value, submitted: saveAction === "SUBMIT", step1Answer: JSON.stringify(buildStepOneAnswer()), step2Answer: JSON.stringify(buildStepTwoAnswer()), step3Answer: JSON.stringify(buildStepThreeAnswer()) }) }
async function loadTaskConfig() { try { const response = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = response?.data || null; currentStep.value = normalizeStep(currentStep.value) } catch (error) { taskConfig.value = null } }
async function loadSavedProgress() {
try {
const response = await getStudentTrainingAnswer(TASK_KEY); const raw = response?.data?.step1Answer; if (!raw) return
const saved = JSON.parse(raw); const validIds = new Set(FAULTS.map((fault) => fault.id))
if (Array.isArray(saved?.orderedFaultIds) && saved.orderedFaultIds.length === FAULTS.length && new Set(saved.orderedFaultIds).size === FAULTS.length && saved.orderedFaultIds.every((id) => validIds.has(id))) {
orderedFaults.value = saved.orderedFaultIds.map((id) => FAULTS.find((fault) => fault.id === id))
}
assignments.value = Object.fromEntries(Object.entries(saved?.assignments || {}).filter(([id, categoryId]) => validIds.has(id) && CATEGORIES.some((category) => category.id === categoryId)))
stepOnePassed.value = Boolean(saved?.passed); checkResult.value = saved?.checkResult || null
const rawStepTwo = response?.data?.step2Answer; if (rawStepTwo) {
const savedStepTwo = JSON.parse(rawStepTwo); const validServiceIds = new Set(SERVICE_ROWS.map((row) => row.id))
serviceAnswers.value = Object.fromEntries(SERVICE_ROWS.map((row) => {
const savedRow = Array.isArray(savedStepTwo?.rows) ? savedStepTwo.rows.find((item) => item?.id === row.id) : null
return [row.id, { process: validServiceIds.has(row.id) ? String(savedRow?.process || "") : "", rule: validServiceIds.has(row.id) ? String(savedRow?.rule || "") : "" }]
}))
stepTwoPassed.value = Boolean(savedStepTwo?.passed); stepTwoResult.value = savedStepTwo?.validationResult || null
}
const rawStepThree = response?.data?.step3Answer; if (rawStepThree) {
const savedStepThree = JSON.parse(rawStepThree)
policyAnswers.value = {
framework: Object.fromEntries(POLICY_ROWS.map((row) => {
const savedPolicy = Array.isArray(savedStepThree?.policies) ? savedStepThree.policies.find((item) => item?.id === row.id) : null
return [row.id, { content: String(savedPolicy?.content || ""), condition: String(savedPolicy?.condition || "") }]
})),
summary: Object.fromEntries(POLICY_SUMMARY_ROWS.map((row) => {
const savedSummary = Array.isArray(savedStepThree?.summaries) ? savedStepThree.summaries.find((item) => item?.id === row.id) : null
return [row.id, String(savedSummary?.content || "")]
})),
}
stepThreePassed.value = Boolean(savedStepThree?.passed); stepThreeResult.value = savedStepThree?.validationResult || null
}
currentStep.value = normalizeStep(response?.data?.currentStep || saved?.currentStep)
} catch (error) { /* Keep a fresh shuffled exercise available when historical progress cannot be read. */ }
}
async function checkAndSave() {
if (checking.value || unassignedFaults.value.length) return
checking.value = true
try {
const items = orderedFaults.value.map((fault) => ({ faultId: fault.id, categoryId: assignments.value[fault.id] }))
const response = await checkAfterSalesServiceStepOne(items)
checkResult.value = response?.data || null; stepOnePassed.value = Boolean(checkResult.value?.allCorrect)
await persist("SAVE")
if (stepOnePassed.value) proxy?.$modal?.msgSuccess("故障分类正确,已保存并解锁下一步"); else proxy?.$modal?.msgWarning("故障分类尚未完全正确,请继续调整")
} catch (error) { proxy?.$modal?.msgError(error?.message || "判题失败,请稍后重试") } finally { checking.value = false }
}
async function goNextStep() { if (!stepOnePassed.value) return; currentStep.value = normalizeStep(currentStep.value + 1); await persist("SAVE") }
function saveCurrentProgress() {
if (currentStep.value === 1) {
if (unassignedFaults.value.length) { proxy?.$modal?.msgWarning("请先将全部故障现象完成归类"); return }
return checkAndSave()
}
if (currentStep.value === 2) return validateAndSaveStepTwo()
return validateAndSaveStepThree()
}
function previousStep() { if (currentStep.value > 1) currentStep.value = normalizeStep(currentStep.value - 1) }
async function nextStep() {
if (currentStep.value >= trainingSteps.value.length) return
if (currentStep.value === 1) {
if (!stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成故障分类并判题通过"); return }
await goNextStep()
return
}
if (await validateAndSaveStepTwo()) currentStep.value = normalizeStep(currentStep.value + 1)
}
function applyExample(rowId) { const row = SERVICE_ROWS.find((item) => item.id === rowId); if (!row) return; serviceAnswers.value = { ...serviceAnswers.value, [rowId]: { process: row.processExample || "", rule: row.ruleExample || "" } }; clearStepTwoGrade() }
function clearStepTwoGrade() { stepTwoPassed.value = false; stepTwoResult.value = null }
async function validateAndSaveStepTwo() {
if (savingStepTwo.value) return false; savingStepTwo.value = true
try {
const rows = buildStepTwoAnswer().rows; const response = await checkAfterSalesServiceStepTwo({ rows })
stepTwoResult.value = response?.data || null; stepTwoPassed.value = Boolean(stepTwoResult.value?.valid)
if (!stepTwoPassed.value) { proxy?.$modal?.msgWarning(stepTwoResult.value?.message || "请完成五类故障的处理流程和核心规则"); return false }
await persist("SAVE"); proxy?.$modal?.msgSuccess("服务分级处理方案已保存"); return true
} catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); return false } finally { savingStepTwo.value = false }
}
function clearStepThreeGrade() { stepThreePassed.value = false; stepThreeResult.value = null }
function applyPolicyExample(rowId) {
const row = POLICY_ROWS.find((item) => item.id === rowId); if (!row) return
policyAnswers.value = { ...policyAnswers.value, framework: { ...policyAnswers.value.framework, [rowId]: { content: row.contentExample || "", condition: row.conditionExample || "" } } }
clearStepThreeGrade()
}
function applySummaryExample(rowId) {
const row = POLICY_SUMMARY_ROWS.find((item) => item.id === rowId); if (!row) return
policyAnswers.value = { ...policyAnswers.value, summary: { ...policyAnswers.value.summary, [rowId]: row.example || "" } }
clearStepThreeGrade()
}
async function validateAndSaveStepThree() {
if (savingStepThree.value) return; savingStepThree.value = true
try {
const answer = buildStepThreeAnswer(); const response = await checkAfterSalesServiceStepThree(answer)
stepThreeResult.value = response?.data || null; stepThreePassed.value = Boolean(stepThreeResult.value?.valid)
if (!stepThreePassed.value) { proxy?.$modal?.msgWarning(stepThreeResult.value?.message || "请完成售后政策框架与详情页摘要"); return }
currentStep.value = normalizeStep(3); await persist("SUBMIT"); proxy?.$modal?.msgSuccess("售后政策已校验通过,任务提交成功")
} catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试") } finally { savingStepThree.value = false }
}
function switchStep(step) {
if (step > 1 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成故障分类并判题通过"); return }
if (step > 2 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成服务分级与处理方案"); return }
if (step > 3 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成售后政策设计"); return }
currentStep.value = normalizeStep(step)
}
function exportOutcome() { const blob = new Blob([JSON.stringify({ currentStep: currentStep.value, step1: buildStepOneAnswer(), step2: buildStepTwoAnswer(), step3: buildStepThreeAnswer() }, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${taskTitle.value}-实训成果.json`; link.click(); URL.revokeObjectURL(url) }
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.after-sales-page {
/* 主色:教育蓝 */
--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;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 主核心容器:透明化,无蓝边无蓝底 */
.after-sales-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header:白卡 + 左侧教育蓝竖条 ===== */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px 16px 16px 20px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
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;
color: var(--student-accent-deep);
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed 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-action-strip {
margin: 0;
padding: 10px 16px;
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* ===== workbench:透明化 ===== */
.after-sales-workbench {
display: grid;
gap: 10px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.section-eyebrow {
margin: 0;
color: var(--student-accent);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* ===== step-card:透明化 ===== */
.step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.step-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-chip {
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 4px 10px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 600;
white-space: nowrap;
}
.step-desc {
margin: 14px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 按钮四级层级(教育蓝极简) ===== */
.outline-action,
.btn-nav {
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-size: var(--student-fs-button);
font-weight: 700;
font-family: inherit;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease,
color 0.16s ease, transform 0.16s ease,
box-shadow 0.16s ease;
}
.outline-action:hover:not(:disabled),
.btn-nav:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.task-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
/* ===== Step 01:拖拽分类区 ===== */
.classification-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.45fr);
gap: 16px;
align-items: start;
}
.fault-lanes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.fault-lane,
.unassigned-panel {
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.fault-lane {
min-height: 228px;
padding: 14px;
}
.fault-lane--over,
.unassigned-panel--over {
border-color: var(--student-accent);
background: var(--student-accent-soft);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.08);
}
.lane-head,
.unassigned-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.lane-head>div {
display: flex;
align-items: center;
gap: 9px;
}
.lane-index {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 700;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.lane-head h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.lane-head>span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 700;
}
.fault-lane>p,
.unassigned-panel>p {
min-height: 0;
margin: 8px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.55;
}
.lane-drop-zone,
.unassigned-list {
display: grid;
gap: 8px;
}
.drop-hint,
.unassigned-empty {
display: grid;
place-items: center;
min-height: 90px;
border: 1px dashed var(--student-panel-border);
border-radius: 10px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
text-align: center;
}
.unassigned-panel {
position: sticky;
top: 18px;
padding: 14px;
}
.unassigned-head strong {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent);
font-size: var(--student-fs-body-sm);
font-weight: 700;
}
.unassigned-list {
max-height: 560px;
overflow-y: auto;
padding-right: 2px;
}
/* fault-card(由 defineComponent 渲染,scoped 选择器加 [data-v-xxx] 后仍能命中) */
.fault-card {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 9px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
cursor: grab;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.fault-card:hover,
.fault-card.dragging {
border-color: var(--student-accent);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08);
transform: translateY(-1px);
color: var(--student-accent);
}
.fault-handle {
color: var(--student-accent);
font-size: 18px;
line-height: 1;
}
.fault-card p {
grid-column: 2;
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.55;
}
.fault-select-label {
grid-column: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
white-space: nowrap;
}
.fault-select-label select {
width: 126px;
max-width: none;
flex: 0 0 126px;
padding: 4px 22px 4px 7px;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-body);
}
/* ===== 结果反馈 ===== */
.result-message {
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid rgba(180, 83, 9, 0.3);
border-radius: 10px;
color: #92400e;
background: rgba(245, 158, 11, 0.08);
font-size: var(--student-fs-body-sm);
}
.result-message.success {
border-color: rgba(74, 157, 110, 0.35);
color: #065f46;
background: rgba(74, 157, 110, 0.08);
}
/* ===== 表格(Step 02 / Step 03) ===== */
.service-table-wrap {
overflow-x: auto;
}
.service-table {
width: 100%;
min-width: 990px;
border-collapse: collapse;
table-layout: fixed;
}
.service-table th,
.service-table td {
padding: 12px 14px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
vertical-align: top;
}
.service-table tr:last-child th,
.service-table tr:last-child td {
border-bottom: 0;
}
.service-table th:last-child,
.service-table td:last-child {
border-right: 0;
}
.service-table thead th {
padding: 12px 14px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.service-table tbody th {
width: 15%;
vertical-align: middle;
color: var(--student-text);
background: transparent;
font-size: var(--student-fs-body);
text-align: left;
font-weight: 700;
}
.service-table tbody td {
width: 28.33%;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.service-row-index {
display: inline-block;
margin-right: 6px;
padding: 1px 6px;
border: 1px solid var(--student-panel-border);
border-radius: 4px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 700;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.service-table em {
color: var(--student-text-secondary);
font-style: italic;
font-size: var(--student-fs-body-sm);
}
.service-table textarea {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 80px;
padding: 8px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
outline: none;
resize: vertical;
color: var(--student-text);
background: transparent;
font: inherit;
font-size: var(--student-fs-body);
line-height: 1.6;
transition: border-color 0.16s ease;
}
.service-table textarea::placeholder {
color: var(--student-text-secondary);
}
.service-table textarea:focus {
outline: 2px solid rgba(37, 99, 235, 0.2);
outline-offset: -2px;
border-color: var(--student-accent);
}
.example-button {
margin-top: 8px;
padding: 3px 10px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 6px;
color: var(--student-accent);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-caption);
font-weight: 700;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
}
.example-button:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.example-copy {
display: block;
margin-top: 6px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
line-height: 1.5;
white-space: pre-line;
}
/* ===== Step 03:政策设计 ===== */
.policy-design-card {
display: grid;
gap: 20px;
}
.policy-section {
display: grid;
gap: 8px;
}
.policy-table {
min-width: 1040px;
}
.policy-table tbody th {
width: 18%;
}
.policy-table tbody td {
width: 41%;
}
.policy-summary-table {
min-width: 760px;
}
.policy-summary-table tbody th {
width: 20%;
}
.policy-summary-table tbody td {
width: 80%;
}
.policy-summary-table textarea {
min-height: 70px;
}
/* ===== placeholder-card ===== */
.placeholder-card {
min-height: 200px;
}
.placeholder-card>p:not(.section-eyebrow) {
margin: 12px 0;
color: var(--student-text-secondary);
line-height: 1.6;
}
/* ===== 响应式 ===== */
@media (max-width: 1180px) {
.after-sales-page {
padding: 16px;
}
}
@media (max-width: 850px) {
.classification-workspace {
grid-template-columns: 1fr;
}
.unassigned-panel {
position: static;
}
.fault-lanes {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.after-sales-page {
padding: 12px;
}
.task-header {
flex-direction: column;
align-items: flex-start;
}
.after-sales-toolbar {
width: 100%;
justify-content: flex-start;
}
.fault-lanes {
grid-template-columns: 1fr;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
width: 100%;
}
.btn-nav {
width: 100%;
}
}
/* ===== 底部步骤导航按钮 ===== */
.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;
}
/* ===== 步骤内小节标题(蓝底序号,与 diagnosis.vue 统一) ===== */
.section-heading {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
>span {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent, #2563eb);
font-size: var(--student-fs-body, 14px);
font-weight: 700;
}
h3 {
margin: 0;
color: var(--student-text, #1f2937);
font-size: var(--student-fs-h1, 18px);
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--student-text-secondary, #6b7280);
font-size: var(--student-fs-body, 14px);
line-height: 1.6;
}
}
</style>
<style lang="scss">
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝主题)
* 覆盖全局 !important 规则
* ============================================================ */
#app .studentLayout section.app-main:has(.after-sales-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector {
display: 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-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: 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;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action {
display: none !important;
}
/* 未到达 hover */
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-marker {
background: #2563eb !important;
color: #ffffff !important;
}
}
/* 已完成 */
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.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-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-marker {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ===== 窄屏 900px:横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.after-sales-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.after-sales-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.after-sales-page .training-material-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 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;
box-shadow: none !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
.el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !important;
}
&:hover,
&:focus {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
/* ============================================================
* 顶部导出按钮(outline-action)→ 教育蓝描边
* ============================================================ */
#app .studentLayout section.app-main:has(.after-sales-page) .outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
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;
box-shadow: none !important;
transition:
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease !important;
.el-icon {
font-size: var(--student-fs-h2) !important;
color: inherit !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: translateY(-1px) !important;
}
}
/* ===== 顶部工具栏间距 ===== */
.after-sales-page .training-task-toolbar,
.after-sales-page .after-sales-toolbar {
gap: 8px;
}
</style>