|
|
<template>
|
|
|
<div class="after-sales-page student-training-shell">
|
|
|
<main class="training-core">
|
|
|
<section class="task-header">
|
|
|
<div class="task-badge"><el-icon><Service /></el-icon> 产品规划与设计</div>
|
|
|
<h1 class="task-title">{{ taskTitle }}</h1>
|
|
|
</section>
|
|
|
|
|
|
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
|
|
|
|
|
|
<section class="task-card">
|
|
|
<div class="workbench-head">
|
|
|
<div>
|
|
|
<p class="section-eyebrow">After-sales diagnostic routing</p>
|
|
|
<h2>售后故障类型分类</h2>
|
|
|
</div>
|
|
|
<div class="workbench-actions">
|
|
|
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
|
|
|
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<nav 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>{{ step }}</strong><small>{{ currentStep === index + 1 ? '当前步骤' : '点击切换' }} →</small></span>
|
|
|
</button>
|
|
|
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
|
|
|
</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">
|
|
|
<FaultCard v-for="fault in faultsByCategory(category.id)" :key="fault.id" :fault="fault" />
|
|
|
<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">
|
|
|
<FaultCard v-for="fault in unassignedFaults" :key="fault.id" :fault="fault" />
|
|
|
<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 ? '保存中…' : '保存并进入第 4 步' }}<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, defineComponent, getCurrentInstance, h, 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 TrainingTaskBrief from "@/views/components/TrainingTaskBrief.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 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(() => parseTrainingArray(taskConfig.value?.steps, fallbackSteps));
|
|
|
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" : "" })));
|
|
|
|
|
|
const FaultCard = defineComponent({
|
|
|
props: { fault: { type: Object, required: true } },
|
|
|
setup(props) {
|
|
|
return () => h("article", {
|
|
|
class: ["fault-card", { dragging: dragFaultId.value === props.fault.id }], draggable: !checking.value,
|
|
|
onDragstart: () => onDragStart(props.fault.id), onDragend: onDragEnd,
|
|
|
}, [
|
|
|
h("span", { class: "fault-handle", title: "拖拽分类" }, "⠿"),
|
|
|
h("p", props.fault.text),
|
|
|
h("label", { class: "fault-select-label" }, [h("span", "分类"), h("select", {
|
|
|
value: assignments.value[props.fault.id] || "", disabled: checking.value,
|
|
|
onChange: (event) => setCategory(props.fault.id, event.target.value),
|
|
|
}, [h("option", { value: "" }, "待分类"), ...CATEGORIES.map((category) => h("option", { value: category.id }, category.name))])]),
|
|
|
]);
|
|
|
},
|
|
|
});
|
|
|
|
|
|
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, 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(4); await persist("SAVE"); proxy?.$modal?.msgSuccess("售后政策已保存,已进入第 4 步");
|
|
|
} 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{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:36px;background:rgba(8,18,28,.6);box-shadow:inset 0 1px 0 rgba(169,229,255,.08),0 22px 48px rgba(0,0,0,.22)}.task-header{margin-bottom:24px}.task-badge,.step-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-badge{min-height:28px;padding:4px 14px}.task-title{margin:12px 0 0;color:#fff;font-size:30px;font-weight:900;line-height:1.3}.task-card,.step-card{border:1px solid rgba(82,174,226,.42);border-radius:26px;background:rgba(0,25,42,.64)}.task-card{margin-top:28px;padding:18px 22px 22px}.workbench-head{display:flex;justify-content:space-between;gap:18px;margin-bottom:22px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800;letter-spacing:.06em}.workbench-head h2,.step-title-row h3,.placeholder-card h3{margin:5px 0 0;color:#fff;font-size:24px;line-height:1.3}.workbench-actions,.task-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.workbench-actions{justify-content:flex-end}.outline-action,.btn-nav{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 18px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#e9fbff;background:rgba(17,126,178,.36);font:inherit;font-size:14px;font-weight:800;cursor:pointer}.outline-action:hover,.btn-nav:hover:not(:disabled){border-color:#74ebff;background:rgba(18,146,204,.5)}button:disabled{cursor:not-allowed;opacity:.45}.btn-submit,.btn-primary{border-color:transparent;background:linear-gradient(135deg,#08a5d6,#147bb9)}.step-indicator{display:flex;align-items:center;margin:0 -12px 30px;padding:18px 22px;overflow-x:auto;border:1px solid rgba(0,180,255,.3);border-radius:18px;background:rgba(1,18,31,.62)}.step-tab{display:flex;flex:1 0 145px;align-items:center;gap:10px;min-height:66px;padding:8px;border:1px solid rgba(68,141,177,.35);border-radius:8px;color:#9ab3d0;background:rgba(3,27,43,.52);font:inherit;text-align:left;cursor:pointer}.step-tab.active{border-color:#53d8fb;color:#fff;background:linear-gradient(135deg,rgba(7,156,207,.85),rgba(13,105,161,.92))}.step-tab.complete{color:#c8edff}.step-marker{display:grid;place-items:center;width:34px;height:34px;border:1px solid currentColor;border-radius:50%;font-weight:900}.step-copy{display:grid;gap:3px}.step-copy small{font-size:11px;color:inherit}.step-connector{flex:0 0 24px;height:2px;background:rgba(86,198,235,.48)}.step-card{padding:24px}.step-title-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.step-chip{padding:7px 12px}.step-desc{margin:18px 0 24px;color:#d7ecff;font-size:15px;line-height:1.8}.classification-workspace{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:20px;align-items:start}.fault-lanes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.fault-lane,.unassigned-panel{border:1px solid rgba(102,194,232,.36);border-radius:17px;background:linear-gradient(145deg,rgba(12,67,93,.52),rgba(3,22,36,.8));transition:border-color .2s,box-shadow .2s,background .2s}.fault-lane{min-height:260px;padding:16px}.fault-lane--over,.unassigned-panel--over{border-color:#75ebff;background:rgba(8,103,143,.42);box-shadow:0 0 0 3px rgba(49,202,241,.12)}.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:28px;height:28px;border:1px solid rgba(102,224,255,.7);border-radius:50%;color:#8eeaff;font-size:11px;font-weight:900}.lane-head h4{margin:0;color:#fff;font-size:16px}.lane-head>span{color:#91dff7;font-size:12px;font-weight:800}.fault-lane>p,.unassigned-panel>p{min-height:40px;margin:10px 0;color:#a7cad9;font-size:12px;line-height:1.65}.lane-drop-zone,.unassigned-list{display:grid;gap:10px}.drop-hint,.unassigned-empty{display:grid;place-items:center;min-height:104px;border:1px dashed rgba(94,208,241,.38);border-radius:11px;color:#78bfd9;font-size:12px;text-align:center}.unassigned-panel{position:sticky;top:18px;padding:18px}.unassigned-head strong{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;color:#fff;background:#0b92c4;font-size:12px}.fault-card{display:grid;grid-template-columns:18px minmax(0,1fr);gap:8px;padding:11px;border:1px solid rgba(93,191,226,.38);border-radius:11px;background:rgba(3,27,43,.9);cursor:grab;transition:.2s}.fault-card:hover,.fault-card.dragging{border-color:#75e9ff;box-shadow:0 8px 16px rgba(0,0,0,.2);transform:translateY(-1px)}.fault-handle{color:#74dff7;font-size:18px;line-height:1}.fault-card p{grid-column:2;margin:0;color:#e4f4ff;font-size:13px;line-height:1.55}.fault-select-label{grid-column:2;display:flex;align-items:center;justify-content:space-between;gap:8px;color:#88bbce;font-size:11px}.fault-select-label select{max-width:142px;padding:4px 22px 4px 7px;border:1px solid rgba(95,188,221,.6);border-radius:6px;color:#dff7ff;background:#0b2638;font:inherit;font-size:11px}.result-message{margin:22px 0 0;padding:11px 14px;border:1px solid rgba(255,186,83,.65);border-radius:10px;color:#ffe1a0;background:rgba(153,96,12,.18)}.result-message.success{border-color:#43dfb5;color:#c6ffe8;background:rgba(18,143,103,.2)}.task-actions{justify-content:flex-end;margin-top:24px}.placeholder-card{min-height:250px}.placeholder-card>p:not(.section-eyebrow){margin:18px 0;color:#cae6f5;line-height:1.8}@media (max-width:1180px){.after-sales-page{grid-template-columns:1fr}.training-core{padding:22px}}@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}.training-core{padding:16px;border-radius:20px}.task-card,.step-card{padding:16px}.workbench-head{flex-direction:column}.workbench-actions,.outline-action,.btn-nav{width:100%}.step-indicator{align-items:stretch;flex-direction:column;margin-right:0;margin-left:0}.step-tab{flex:0 0 auto;width:100%}.step-connector{flex:0 0 20px;width:2px;height:20px;margin-left:25px}.fault-lanes{grid-template-columns:1fr}.task-title{font-size:25px}}
|
|
|
.service-table-wrap{overflow-x:auto;border:1px solid rgba(90,190,231,.38);border-radius:16px;background:rgba(2,21,35,.45)}.service-table{width:100%;min-width:990px;border-collapse:collapse;table-layout:fixed}.service-table th,.service-table td{padding:14px;border-right:1px solid rgba(104,188,220,.26);border-bottom:1px solid rgba(104,188,220,.26);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:13px 12px;color:#eaffff;background:linear-gradient(90deg,rgba(7,143,196,.92),rgba(13,100,159,.92));font-size:14px;text-align:center}.service-table tbody th{width:15%;vertical-align:middle;color:#f4fbff;background:rgba(7,54,79,.48);font-size:14px;text-align:center}.service-table tbody td{width:28.33%;color:#c4e7f4;font-size:13px;line-height:1.7}.service-row-index{display:block;margin-bottom:7px;color:#6de1fb;font-size:11px;font-weight:900;letter-spacing:.1em}.service-table em{color:#9cc2d3;font-style:italic}.service-table textarea{display:block;box-sizing:border-box;width:100%;min-height:92px;padding:10px;border:1px solid rgba(93,190,224,.38);border-radius:9px;outline:none;resize:vertical;color:#eaf9ff;background:rgba(1,14,24,.56);font:inherit;font-size:13px;line-height:1.6;transition:border-color .2s,box-shadow .2s}.service-table textarea::placeholder{color:#628da1}.service-table textarea:focus{border-color:#66e1fb;box-shadow:0 0 0 3px rgba(58,200,240,.12)}.example-button{margin-top:9px;padding:4px 9px;border:1px solid rgba(94,214,244,.56);border-radius:999px;color:#d7f9ff;background:rgba(12,125,172,.36);font:inherit;font-size:11px;font-weight:800;cursor:pointer}.example-button:hover{border-color:#8ef1ff;background:rgba(16,152,204,.53)}.example-copy{display:block;margin-top:8px;color:#86afc1;font-size:11px;line-height:1.55;white-space:pre-line}
|
|
|
.policy-design-card{display:grid;gap:26px}.policy-design-card .step-title-row{margin-bottom:-8px}.policy-section{display:grid;gap:10px}.policy-section-heading{display:flex;align-items:center;gap:9px}.policy-section-heading span{display:grid;place-items:center;width:27px;height:27px;border:1px solid rgba(100,221,247,.56);border-radius:8px;color:#6ee4ff;font-size:12px;font-weight:900}.policy-section-heading h4{margin:0;color:#effaff;font-size:18px}.policy-section>p{margin:0;color:#b7d8e6;font-size:13px;line-height:1.8}.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:78px}
|
|
|
</style>
|