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

1410 lines
53 KiB
Vue

<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>
<div class="after-sales-toolbar">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</section>
<section class="after-sales-workbench">
<div class="workbench-head">
<div>
<p class="section-eyebrow">After-sales diagnostic routing</p>
<h2>售后故障类型分类</h2>
</div>
</div>
<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">互联网产品的售后服务除硬件维修外,还需处理配网、云端连接与软件控制等问题。请将右侧被打乱的故障现象拖入对应的故障类型;完成全部分类并判题正确后才能进入下一步。</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 btn-submit" :disabled="checking || unassignedFaults.length > 0" @click="checkAndSave">{{ checking ? '判题中…' : '检查分类并保存' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetFaults"><el-icon><RefreshLeft /></el-icon>重新打乱</button>
<button type="button" class="btn-nav btn-primary" :disabled="!stepOnePassed" @click="goNextStep">进入第 2 步<el-icon><ArrowRight /></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>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="savingStepTwo" @click="switchStep(1)"><el-icon><ArrowLeft /></el-icon>返回第 1 步</button>
<button type="button" class="btn-nav btn-submit" :disabled="savingStepTwo" @click="validateAndSaveStepTwo">{{ savingStepTwo ? '保存中…' : '保存并进入第 3 步' }}<el-icon><ArrowRight /></el-icon></button>
</div>
</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">根据第 2 步的分级处理方案,制定可用于产品说明书和电商详情页的售后政策。斜体文字是填写示例,可复制后按案例信息调整,不参与标准答案判定。</p>
<section class="policy-section" aria-labelledby="policy-framework-title">
<div class="policy-section-heading"><span>(一)</span><h4 id="policy-framework-title">填写售后政策框架表</h4></div>
<p>请结合案例资料中的产品信息、行业法规与竞品售后政策,明确退换货、质保、维修、响应时效和补偿方案。</p>
<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="policy-section-heading"><span>(二)</span><h4 id="policy-summary-title">填写电商详情页售后信息摘要</h4></div>
<p>将政策核心信息提炼为消费者友好的简明摘要,用于详情页"售后保障"模块展示。</p>
<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>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="savingStepThree" @click="switchStep(2)"><el-icon><ArrowLeft /></el-icon>返回第 2 步</button>
<button type="button" class="btn-nav btn-submit" :disabled="savingStepThree" @click="validateAndSaveStepThree">{{ savingStepThree ? '提交中…' : '校验并提交任务' }}<el-icon><ArrowRight /></el-icon></button>
</div>
</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>
<div class="task-actions"><button type="button" class="btn-nav" @click="switchStep(1)"><el-icon><ArrowLeft /></el-icon>返回第 1 步</button></div>
</section>
</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, Download, RefreshLeft, Service } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.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(false);
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 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 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; 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; }
currentStep.value = normalizeStep(3); await persist("SAVE"); proxy?.$modal?.msgSuccess("服务分级处理方案已保存,已进入第 3 步");
} catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); } 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-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: #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: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; 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: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
.after-sales-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: 0 0 auto;
justify-content: flex-end;
max-width: 100%;
}
/* ===== workbench:透明化 ===== */
.after-sales-workbench {
display: grid;
gap: 10px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.workbench-head {
display: flex;
align-items: flex-start;
gap: 16px;
margin: 0;
padding: 0;
border-bottom: 0;
}
.section-eyebrow {
margin: 0;
color: var(--student-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workbench-head h2 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
/* ===== 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: 11px;
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);
}
.btn-nav.btn-primary,
.btn-nav.btn-submit {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-weight: 700;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
}
.btn-nav.btn-primary:hover:not(:disabled),
.btn-nav.btn-submit:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
color: #ffffff;
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
.btn-save {
border: 1px solid #e5e7eb;
color: #374151;
background: #f3f4f6;
box-shadow: none;
}
.btn-save:hover:not(:disabled) {
border-color: #d1d5db;
background: #e9ecef;
color: #374151;
transform: none;
box-shadow: none;
}
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: 11px;
font-weight: 700;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.lane-head h4 {
margin: 0;
color: var(--student-text);
font-size: 14px;
font-weight: 700;
}
.lane-head > span {
color: var(--student-text-secondary);
font-size: 12px;
font-weight: 700;
}
.fault-lane > p,
.unassigned-panel > p {
min-height: 0;
margin: 8px 0;
color: var(--student-text-secondary);
font-size: 12px;
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: 12px;
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: 12px;
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: 16px;
line-height: 1;
}
.fault-card p {
grid-column: 2;
margin: 0;
color: var(--student-text);
font-size: 13px;
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: 11px;
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: 11px;
}
/* ===== 结果反馈 ===== */
.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: 12px;
}
.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: 11px;
font-weight: 700;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.service-table em {
color: var(--student-text-secondary);
font-style: italic;
font-size: 12px;
}
.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: 11px;
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: 11px;
line-height: 1.5;
white-space: pre-line;
}
/* ===== Step 03:政策设计 ===== */
.policy-design-card {
display: grid;
gap: 20px;
}
.policy-section {
display: grid;
gap: 8px;
}
.policy-section-heading {
display: flex;
align-items: center;
gap: 9px;
}
.policy-section-heading span {
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: 12px;
font-weight: 700;
}
.policy-section-heading h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.policy-section > p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.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; }
}
</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 {
flex: 1 1 0 !important;
max-width: 240px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: normal !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
/* 编号小徽章 */
.step-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: 12px !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: normal !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: 13px !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: 14px !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: 13px !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: 14px !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>