595 lines
15 KiB
Vue
595 lines
15 KiB
Vue
<!-- =============================================================================
|
||
// 企微IT智能服务台 — H5用户端排查步骤交互组件
|
||
// =============================================================================
|
||
// 说明:与坐席端同构的排查步骤功能,用户可交互
|
||
// 核心功能:
|
||
// 1. 横向路径进度条(和坐席端一致的 done/current/pending 三态)
|
||
// 2. 步骤节点:展示当前操作说明(图文指引)
|
||
// 3. 决策节点:展示问答卡片,用户点击选项聚焦问题范围
|
||
// 4. 坐席和用户同步看到当前路径和节点
|
||
// 数据流:
|
||
// 坐席推进步骤 → WebSocket 推送 → store 更新 → 本组件响应式渲染
|
||
// 用户选择选项 → WebSocket 推送 → 坐席端同步看到用户的选择
|
||
// ============================================================================= -->
|
||
|
||
<template>
|
||
<!-- 仅在有排查步骤时显示 -->
|
||
<div v-if="steps.length > 0" class="ts-flow">
|
||
<!-- ====== 路径进度条 ====== -->
|
||
<div class="ts-flow__path">
|
||
<div class="ts-flow__path-bar">
|
||
<template v-for="(step, index) in steps" :key="index">
|
||
<div
|
||
class="ts-flow__step"
|
||
:class="`ts-flow__step--${step.status}`"
|
||
>
|
||
<span
|
||
class="ts-flow__dot"
|
||
:class="`ts-flow__dot--${step.status}`"
|
||
>
|
||
<template v-if="step.status === 'done'">✓</template>
|
||
<template v-else>{{ index + 1 }}</template>
|
||
</span>
|
||
<span class="ts-flow__label">{{ step.label }}</span>
|
||
</div>
|
||
<!-- 步骤间连接线 -->
|
||
<span
|
||
v-if="index < steps.length - 1"
|
||
class="ts-flow__arrow"
|
||
:class="{ 'ts-flow__arrow--active': step.status === 'done' }"
|
||
>→</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ====== 交互卡片区域 ====== -->
|
||
<div class="ts-flow__card">
|
||
|
||
<!-- ===== 决策节点:问答交互卡片 ===== -->
|
||
<template v-if="currentNode && currentNode.type === 'decision'">
|
||
<div class="ts-flow__question">
|
||
<div class="ts-flow__question-header">
|
||
<span class="ts-flow__question-icon">❓</span>
|
||
<span class="ts-flow__question-text">{{ currentNode.label }}</span>
|
||
</div>
|
||
<!-- 选项按钮 -->
|
||
<div class="ts-flow__options">
|
||
<button
|
||
class="ts-flow__option ts-flow__option--yes"
|
||
@click="handleOptionSelect('yes')"
|
||
:disabled="optionSubmitting"
|
||
>
|
||
<span class="ts-flow__option-icon">✅</span>
|
||
<span>{{ currentNode.yes_branch?.label || '是' }}</span>
|
||
</button>
|
||
<button
|
||
class="ts-flow__option ts-flow__option--no"
|
||
@click="handleOptionSelect('no')"
|
||
:disabled="optionSubmitting"
|
||
>
|
||
<span class="ts-flow__option-icon">❌</span>
|
||
<span>{{ currentNode.no_branch?.label || '否' }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ===== 步骤节点:操作说明卡片 ===== -->
|
||
<template v-else-if="currentNode && currentNode.type === 'step'">
|
||
<div class="ts-flow__instruction">
|
||
<div class="ts-flow__instruction-header">
|
||
<span class="ts-flow__instruction-icon">📋</span>
|
||
<span class="ts-flow__instruction-title">正在执行:{{ currentNode.label }}</span>
|
||
</div>
|
||
<!-- 步骤说明(如果有子节点,展示子步骤概要) -->
|
||
<div v-if="currentNode.children && currentNode.children.length > 0" class="ts-flow__instruction-steps">
|
||
<div
|
||
v-for="(child, idx) in currentNode.children"
|
||
:key="child.id"
|
||
class="ts-flow__sub-step"
|
||
:class="`ts-flow__sub-step--${child.status || 'pending'}`"
|
||
>
|
||
<span class="ts-flow__sub-dot">{{ idx + 1 }}</span>
|
||
<span class="ts-flow__sub-label">{{ child.label }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- 提示文字 -->
|
||
<div class="ts-flow__instruction-hint">
|
||
<span>💡 请按坐席指引操作,完成后坐席会推进到下一步</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ===== 无当前节点:等待坐席启动 ===== -->
|
||
<template v-else>
|
||
<div class="ts-flow__waiting">
|
||
<span class="ts-flow__waiting-icon">⏳</span>
|
||
<span>坐席正在选择排查流程,请稍候...</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ===== 底部同步状态 ===== -->
|
||
<div class="ts-flow__sync-status">
|
||
<span class="ts-flow__sync-dot"></span>
|
||
<span>排查进度实时同步 · {{ templateName }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* TroubleshootFlow 排查步骤交互组件
|
||
*
|
||
* 与坐席端 TroubleshootBar 同构但面向用户:
|
||
* - 坐席端的决策树 → 用户端的问答卡片(用户点击选项)
|
||
* - 坐席端的步骤路径 → 用户端的横向进度条
|
||
* - 坐席端的流程图节点 → 用户端的操作说明卡片
|
||
*
|
||
* 交互流程:
|
||
* 1. 坐席选择排查模板 → WebSocket 推送模板数据到 H5
|
||
* 2. H5 展示第一个节点(通常是步骤节点)
|
||
* 3. 遇到决策节点 → 展示问答卡片,用户点击选项
|
||
* 4. 用户选择 → WebSocket 发送选择 → 后端更新流程状态
|
||
* 5. 坐席和用户同步看到新的当前节点
|
||
*/
|
||
import { computed, ref } from 'vue'
|
||
import { useConversationStore } from '@/stores/conversation'
|
||
import type { FlowchartNode } from '@/api/troubleshooting'
|
||
|
||
// TODO: 阶段二实现 WebSocket 时,从共享模块导入类型
|
||
// 目前使用坐席端已有的 FlowchartNode 类型
|
||
|
||
const store = useConversationStore()
|
||
|
||
/** 用户选择选项时防重复提交 */
|
||
const optionSubmitting = ref(false)
|
||
|
||
/** 排查步骤列表(路径进度) — 从 store 获取 */
|
||
const steps = computed(() => {
|
||
return store.troubleshootingSteps || []
|
||
})
|
||
|
||
/** 排查模板名称 — 从 store 获取 */
|
||
const templateName = computed(() => {
|
||
return store.troubleshootingTemplateName || '排查中'
|
||
})
|
||
|
||
/** 当前活跃的流程图节点 — 从 store 获取 */
|
||
const currentNode = computed<FlowchartNode | null>(() => {
|
||
return store.troubleshootingCurrentNode || null
|
||
})
|
||
|
||
/**
|
||
* 用户选择决策节点的选项
|
||
*
|
||
* @param option - 'yes' 或 'no',对应 yes_branch / no_branch
|
||
*
|
||
* 流程:
|
||
* 1. 本地更新状态(即时反馈)
|
||
* 2. 通过 WebSocket 推送用户选择给坐席端
|
||
* 3. 坐席端确认后,后端更新流程状态
|
||
* 4. 双端同步新的当前节点
|
||
*/
|
||
function handleOptionSelect(option: 'yes' | 'no'): void {
|
||
if (optionSubmitting.value) return
|
||
optionSubmitting.value = true
|
||
|
||
try {
|
||
// 本地即时反馈:标记当前决策节点为 done
|
||
if (currentNode.value) {
|
||
currentNode.value.status = 'done'
|
||
}
|
||
|
||
// 确定下一个节点
|
||
const nextNode = option === 'yes'
|
||
? currentNode.value?.yes_branch
|
||
: currentNode.value?.no_branch
|
||
|
||
if (nextNode) {
|
||
// 更新 store 中的当前节点
|
||
store.troubleshootingCurrentNode = nextNode
|
||
nextNode.status = 'current'
|
||
|
||
// 如果下一个节点是步骤节点,更新路径进度
|
||
if (nextNode.type === 'step') {
|
||
updatePathSteps(nextNode)
|
||
}
|
||
}
|
||
|
||
// TODO: 阶段二 — 通过 WebSocket 发送用户选择
|
||
// websocket.send({
|
||
// type: 'troubleshooting_choice',
|
||
// conversation_id: store.currentConversation?.conversation_id,
|
||
// node_id: currentNode.value?.id,
|
||
// choice: option,
|
||
// })
|
||
console.log('[TroubleshootFlow] 用户选择:', option, '下一节点:', nextNode?.label)
|
||
} finally {
|
||
// 延迟重置,防止快速重复点击
|
||
setTimeout(() => {
|
||
optionSubmitting.value = false
|
||
}, 500)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新路径步骤状态
|
||
* 根据当前节点找到对应的路径步骤,更新为 current
|
||
*/
|
||
function updatePathSteps(activeNode: FlowchartNode): void {
|
||
const currentSteps = store.troubleshootingSteps
|
||
if (!currentSteps || currentSteps.length === 0) return
|
||
|
||
// 在路径步骤中查找匹配当前节点的标签
|
||
const matchedIndex = currentSteps.findIndex(s => s.label === activeNode.label)
|
||
if (matchedIndex >= 0) {
|
||
// 更新所有步骤状态
|
||
const updated = currentSteps.map((step, i) => ({
|
||
...step,
|
||
status: i < matchedIndex ? 'done' as const
|
||
: i === matchedIndex ? 'current' as const
|
||
: 'pending' as const,
|
||
}))
|
||
store.troubleshootingSteps = updated
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ===== 主容器 ===== */
|
||
.ts-flow {
|
||
margin: 8px 0;
|
||
}
|
||
|
||
/* ===== 路径进度条 ===== */
|
||
.ts-flow__path {
|
||
padding: 10px 14px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px 12px 0 0;
|
||
}
|
||
|
||
.ts-flow__path-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
overflow-x: auto;
|
||
padding-bottom: 4px;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
.ts-flow__path-bar::-webkit-scrollbar {
|
||
height: 2px;
|
||
}
|
||
|
||
.ts-flow__path-bar::-webkit-scrollbar-thumb {
|
||
background: var(--border-color);
|
||
border-radius: 1px;
|
||
}
|
||
|
||
/* 单个步骤 */
|
||
.ts-flow__step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 步骤圆点 */
|
||
.ts-flow__dot {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 已完成步骤 */
|
||
.ts-flow__dot--done {
|
||
background: #22c55e;
|
||
color: #fff;
|
||
}
|
||
|
||
.ts-flow__step--done .ts-flow__label {
|
||
color: var(--text-tertiary);
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
/* 当前步骤 — 脉冲动画 */
|
||
.ts-flow__dot--current {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||
animation: ts-dot-pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
.ts-flow__step--current .ts-flow__label {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 待处理步骤 */
|
||
.ts-flow__dot--pending {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-tertiary);
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
.ts-flow__step--pending .ts-flow__label {
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
/* 步骤标签 */
|
||
.ts-flow__label {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 步骤间箭头 */
|
||
.ts-flow__arrow {
|
||
color: var(--text-tertiary);
|
||
font-size: 10px;
|
||
flex-shrink: 0;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.ts-flow__arrow--active {
|
||
color: #22c55e;
|
||
}
|
||
|
||
/* ===== 交互卡片区域 ===== */
|
||
.ts-flow__card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-top: none;
|
||
border-radius: 0 0 12px 12px;
|
||
padding: 14px;
|
||
}
|
||
|
||
/* ===== 决策节点:问答交互 ===== */
|
||
.ts-flow__question {
|
||
/* 问答卡片 */
|
||
}
|
||
|
||
.ts-flow__question-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.ts-flow__question-icon {
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.ts-flow__question-text {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* 选项按钮容器 */
|
||
.ts-flow__options {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 选项按钮 */
|
||
.ts-flow__option {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 12px 16px;
|
||
border-radius: 10px;
|
||
border: 2px solid var(--border-color);
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.ts-flow__option:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
.ts-flow__option:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* "是"选项 — 绿色主题 */
|
||
.ts-flow__option--yes {
|
||
border-color: rgba(34, 197, 94, 0.3);
|
||
background: rgba(34, 197, 94, 0.06);
|
||
}
|
||
|
||
.ts-flow__option--yes:active:not(:disabled) {
|
||
background: rgba(34, 197, 94, 0.15);
|
||
border-color: #22c55e;
|
||
}
|
||
|
||
/* "否"选项 — 红色主题 */
|
||
.ts-flow__option--no {
|
||
border-color: rgba(239, 68, 68, 0.3);
|
||
background: rgba(239, 68, 68, 0.06);
|
||
}
|
||
|
||
.ts-flow__option--no:active:not(:disabled) {
|
||
background: rgba(239, 68, 68, 0.15);
|
||
border-color: #ef4444;
|
||
}
|
||
|
||
.ts-flow__option-icon {
|
||
font-size: 16px;
|
||
}
|
||
|
||
/* ===== 步骤节点:操作说明 ===== */
|
||
.ts-flow__instruction {
|
||
/* 操作说明卡片 */
|
||
}
|
||
|
||
.ts-flow__instruction-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.ts-flow__instruction-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ts-flow__instruction-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* 子步骤列表 */
|
||
.ts-flow__instruction-steps {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin: 10px 0;
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.ts-flow__sub-step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.ts-flow__sub-dot {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-tertiary);
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
.ts-flow__sub-step--done .ts-flow__sub-dot {
|
||
background: #22c55e;
|
||
color: #fff;
|
||
border-color: #22c55e;
|
||
}
|
||
|
||
.ts-flow__sub-step--done .ts-flow__sub-label {
|
||
color: var(--text-tertiary);
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.ts-flow__sub-step--current .ts-flow__sub-dot {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.ts-flow__sub-step--current .ts-flow__sub-label {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ts-flow__sub-label {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 操作提示 */
|
||
.ts-flow__instruction-hint {
|
||
margin-top: 10px;
|
||
padding: 8px 10px;
|
||
background: var(--accent-soft);
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ===== 等待状态 ===== */
|
||
.ts-flow__waiting {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 0;
|
||
font-size: 13px;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.ts-flow__waiting-icon {
|
||
font-size: 16px;
|
||
animation: ts-waiting 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
/* ===== 底部同步状态 ===== */
|
||
.ts-flow__sync-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid var(--border-color);
|
||
font-size: 11px;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.ts-flow__sync-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: #22c55e;
|
||
animation: ts-sync-blink 3s ease-in-out infinite;
|
||
}
|
||
|
||
/* ===== 动画 ===== */
|
||
@keyframes ts-dot-pulse {
|
||
0%, 100% {
|
||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||
}
|
||
}
|
||
|
||
@keyframes ts-sync-blink {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.3;
|
||
}
|
||
}
|
||
|
||
@keyframes ts-waiting {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
</style>
|