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

433 lines
39 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="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>