Files
wecom_it_smart_desk/frontend-h5/src/components/chat/TroubleshootFlow.vue
T

595 lines
15 KiB
Vue
Raw Normal View History

<!-- =============================================================================
// 企微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>