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

595 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =============================================================================
// 企微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>