feat(C组): C-T1~T13 AI与数据模块完成

- C-T1 Wingman 辅助面板
- C-T2 Wingman 后端接口
- C-T3 排查流程图编辑器
- C-T4 流程图后端接口
- C-T5 流程图H5端展示
- C-T6 运营数据看板
- C-T7 看板数据接口
- C-T8 全局一致性审查(周2)
- C-T9 坐席绩效报表
- C-T10 绩效数据接口+Excel导出
- C-T11 会话智能标注
- C-T12 全局一致性审查(周3)
- C-T13 代码评审+PR创建
This commit is contained in:
Simon
2026-07-02 19:23:03 +08:00
parent fc22de7f4d
commit 6277db3951
5 changed files with 2207 additions and 18 deletions
+19
View File
@@ -89,3 +89,22 @@ export async function suggestTags(conversationId: string): Promise<TagsResult> {
)
return response.data.data
}
/**
* 保存会话标签
* 将标签保存到会话记录中
*
* @param conversationId - 会话ID
* @param tags - 标签字典
* @returns 更新后的会话详情
*/
export async function saveConversationTags(
conversationId: string,
tags: Record<string, any>
): Promise<any> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/tags`,
{ tags }
)
return response.data.data
}
@@ -1,14 +1,149 @@
<!-- =============================================================================
// 企微IT智能服务台 — AI助手面板容器组件(v5.3 重构)
// 企微IT智能服务台 — AI助手面板容器组件(v5.4 重构 + Wingman 集成
// =============================================================================
// 说明:坐席工作台右侧的AI助手面板
// 功能:上下两区域布局
// 功能:
// 顶部:Wingman 触发按钮组(生成回复建议 / 获取知识库引用 / 推荐排查流程)
// Wingman 结果展示区:根据 API 结果展示不同类型的 AI 建议
// 上方 ~1/3:🤖 AI 智能推荐区(1-3张推荐卡片)
// 智能标注区:标签显示 + AI建议 + 手动编辑
// 下方 ~2/3:快速回复区(复用 QuickReplyPanel
// ============================================================================= -->
<template>
<div class="ai-assistant-panel">
<!-- ================================================================== -->
<!-- Wingman 触发按钮组 -->
<!-- ================================================================== -->
<div class="wingman-triggers">
<el-button
class="wingman-btn"
size="small"
:loading="loadingDraft"
:disabled="!hasActiveConversation"
@click="handleGenerateDraft"
title="基于当前对话生成AI回复建议"
>
🤖 生成回复建议
</el-button>
<el-button
class="wingman-btn"
size="small"
:loading="loadingKnowledge"
:disabled="!hasActiveConversation"
@click="handleGetKnowledge"
title="从知识库中检索相关引用"
>
📚 获取知识库引用
</el-button>
<el-button
class="wingman-btn"
size="small"
:loading="loadingFlow"
:disabled="!hasActiveConversation"
@click="handleRecommendFlow"
title="基于问题类型推荐排查流程"
>
🔍 推荐排查流程
</el-button>
</div>
<!-- ================================================================== -->
<!-- Wingman 结果展示区 -->
<!-- ================================================================== -->
<div v-if="wingmanResult" class="wingman-result-section">
<!-- 结果标题栏 -->
<div class="wingman-result-header">
<span class="wingman-result-bar"></span>
<span class="wingman-result-title">{{ wingmanResult.title }}</span>
<el-button
size="small"
text
@click="clearWingmanResult"
title="关闭"
>
</el-button>
</div>
<!-- 回复草稿结果 -->
<div v-if="wingmanResult.type === 'draft'" class="wingman-draft-card">
<div class="draft-content">{{ wingmanResult.data.content }}</div>
<div class="draft-meta">
<el-tag size="small" type="success">
置信度: {{ (wingmanResult.data.confidence * 100).toFixed(0) }}%
</el-tag>
<span v-if="wingmanResult.data.reasoning" class="draft-reasoning">
💡 {{ wingmanResult.data.reasoning }}
</span>
</div>
<div class="draft-actions">
<el-button type="primary" size="small" @click="adoptDraft(wingmanResult.data.content)">
采纳到输入框
</el-button>
<el-button size="small" @click="editDraft(wingmanResult.data.content)">
编辑后发送
</el-button>
</div>
</div>
<!-- 知识库引用结果 -->
<div v-else-if="wingmanResult.type === 'knowledge'" class="wingman-knowledge-card">
<div class="knowledge-section">
<div class="knowledge-label">📋 问题描述</div>
<div class="knowledge-text">{{ wingmanResult.data.problem }}</div>
</div>
<div class="knowledge-section">
<div class="knowledge-label">🔍 原因分析</div>
<div class="knowledge-text">{{ wingmanResult.data.cause }}</div>
</div>
<div class="knowledge-section">
<div class="knowledge-label"> 解决方案</div>
<div class="knowledge-text">{{ wingmanResult.data.solution }}</div>
</div>
<div class="knowledge-actions">
<el-button type="primary" size="small" @click="adoptDraft(wingmanResult.data.solution)">
📝 将方案填入输入框
</el-button>
<el-button size="small" @click="copyKnowledgeToClipboard">
📋 复制全部
</el-button>
</div>
</div>
<!-- 排查流程推荐结果 -->
<div v-else-if="wingmanResult.type === 'flow'" class="wingman-flow-card">
<div class="flow-header">
<span class="flow-icon">🔧</span>
<span class="flow-title">推荐排查流程: {{ wingmanResult.data.category }}</span>
</div>
<div v-if="wingmanResult.data.priority" class="flow-priority">
<el-tag
size="small"
:type="wingmanResult.data.priority === 'high' ? 'danger' : wingmanResult.data.priority === 'medium' ? 'warning' : 'info'"
>
优先级: {{ priorityLabel(wingmanResult.data.priority) }}
</el-tag>
</div>
<div class="flow-steps">
<div
v-for="(tag, idx) in wingmanResult.data.suggested_tags"
:key="idx"
class="flow-step-item"
>
<span class="flow-step-num">{{ idx + 1 }}</span>
<span class="flow-step-label">{{ tag }}</span>
</div>
</div>
<div class="flow-empty" v-if="!wingmanResult.data.suggested_tags || wingmanResult.data.suggested_tags.length === 0">
暂无排查步骤建议请结合知识库信息手动排查
</div>
</div>
</div>
<!-- 分隔线仅在有 Wingman 结果时显示 -->
<div v-if="wingmanResult" class="ai-section-divider"></div>
<!-- ================================================================== -->
<!-- 上方 ~1/3 区域 🤖 AI 智能推荐区 -->
<!-- ================================================================== -->
@@ -39,6 +174,83 @@
<!-- 分隔线 -->
<div class="ai-section-divider"></div>
<!-- ================================================================== -->
<!-- 智能标注区 C-T11 新增 -->
<!-- ================================================================== -->
<div class="ai-tags-section">
<!-- 标题栏 -->
<div class="ai-tags-header">
<span class="ai-tags-bar"></span>
<span class="ai-tags-title">🏷 智能标注</span>
<el-button
size="small"
:loading="loadingTagSuggestions"
@click="handleSuggestTags"
title="获取AI标签建议"
>
AI建议
</el-button>
</div>
<!-- 标签列表 -->
<div class="ai-tags-list">
<!-- 已保存的标签 -->
<div v-if="Object.keys(currentTags).length > 0" class="tags-saved">
<el-tag
v-for="(value, key) in currentTags"
:key="key"
class="tag-item"
:type="getTagType(key as string)"
closable
@close="handleRemoveTag(key as string)"
>
{{ formatTagLabel(key as string, value) }}
</el-tag>
</div>
<!-- AI 建议的标签 -->
<div v-if="suggestedTagList.length > 0" class="tags-suggested">
<div class="tags-suggested-title">AI 建议</div>
<el-tag
v-for="tag in suggestedTagList"
:key="tag"
class="tag-item tag-suggested"
type="info"
@click="handleAddSuggestedTag(tag)"
>
+ {{ tag }}
</el-tag>
</div>
<!-- 无标签时提示 -->
<div v-if="Object.keys(currentTags).length === 0 && suggestedTagList.length === 0" class="ai-tags-empty">
暂无标签点击"AI建议"获取智能标注
</div>
</div>
<!-- 手动添加标签 -->
<div class="ai-tags-add">
<el-input
v-model="newTagKey"
placeholder="标签名"
size="small"
style="width: 100px"
@keyup.enter="handleAddManualTag"
/>
<el-input
v-model="newTagValue"
placeholder="值"
size="small"
style="width: 80px"
@keyup.enter="handleAddManualTag"
/>
<el-button size="small" @click="handleAddManualTag">添加</el-button>
</div>
</div>
<!-- 分隔线 -->
<div class="ai-section-divider"></div>
<!-- ================================================================== -->
<!-- 下方 ~2/3 区域 快速回复区 -->
<!-- ================================================================== -->
@@ -52,11 +264,13 @@
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AiSuggestReply from './AiSuggestReply.vue'
import QuickReplyPanel from './QuickReplyPanel.vue'
import { useConversationStore } from '@/stores/conversation'
import type { DraftResult } from '@/api/wingman'
import type { DraftResult, SummaryResult, TagsResult } from '@/api/wingman'
import { generateDraft, generateSummary, suggestTags, saveConversationTags } from '@/api/wingman'
// ============================================================================
// 类型
@@ -72,6 +286,22 @@ interface AiRecommendation {
confidence: number
}
/** 当前会话的标签类型 */
type ConversationTags = Record<string, any>
/** Wingman 结果类型枚举 */
type WingmanResultType = 'draft' | 'knowledge' | 'flow'
/** Wingman 结果展示数据 */
interface WingmanResultData {
/** 结果类型 */
type: WingmanResultType
/** 标题 */
title: string
/** 原始 API 数据 */
data: DraftResult | SummaryResult | TagsResult
}
// ============================================================================
// 状态
// ============================================================================
@@ -79,22 +309,52 @@ interface AiRecommendation {
/** 会话 Store */
const conversationStore = useConversationStore()
/** AI 推荐列表(Mock 数据,后续由 AI 引擎填充) */
/** AI 推荐列表 */
const recommendations = ref<AiRecommendation[]>([])
/** 当前会话的标签 */
const currentTags = ref<ConversationTags>({})
/** AI 建议的标签列表 */
const suggestedTagList = ref<string[]>([])
/** 加载标签建议中 */
const loadingTagSuggestions = ref(false)
/** 新增标签 - 键 */
const newTagKey = ref('')
/** 新增标签 - 值 */
const newTagValue = ref('')
/** Wingman 当前结果 */
const wingmanResult = ref<WingmanResultData | null>(null)
/** Wingman 加载状态 - 生成回复 */
const loadingDraft = ref(false)
/** Wingman 加载状态 - 知识库 */
const loadingKnowledge = ref(false)
/** Wingman 加载状态 - 排查流程 */
const loadingFlow = ref(false)
// ============================================================================
// 计算属性
// ============================================================================
/** 是否有活跃的会话 */
const hasActiveConversation = computed(() => {
return !!conversationStore.currentConversationId
})
/**
* 从当前会话的 AI 草稿生成推荐列表
* 将 draft 数据映射为推荐卡片格式
*/
const loadRecommendationsFromDraft = computed(() => {
const convId = conversationStore.currentConversationId
if (!convId) return []
// 尝试从 AI 草稿缓存中获取推荐
const convDrafts = conversationStore.aiDrafts.get(convId)
if (!convDrafts || convDrafts.size === 0) return []
@@ -113,11 +373,177 @@ const loadRecommendationsFromDraft = computed(() => {
// 方法
// ============================================================================
/**
* 优先级中文标签
*/
function priorityLabel(priority: string): string {
const map: Record<string, string> = { high: '高', medium: '中', low: '低' }
return map[priority] || priority
}
/**
* 获取标签类型
*/
function getTagType(key: string): '' | 'success' | 'info' | 'warning' | 'danger' {
const tagTypeMap: Record<string, '' | 'success' | 'info' | 'warning' | 'danger'> = {
priority: 'danger',
category: 'warning',
emotion: 'info',
blocking: 'danger',
impact_scope: 'warning',
}
return tagTypeMap[key] || ''
}
/**
* 格式化标签显示文本
*/
function formatTagLabel(key: string, value: any): string {
const labelMap: Record<string, string> = {
priority: '优先级',
category: '分类',
emotion: '情绪',
blocking: '阻断',
impact_scope: '影响范围',
}
const label = labelMap[key] || key
return `${label}: ${value}`
}
// ============================================================================
// Wingman 触发方法
// ============================================================================
/**
* Wingman 触发 - 生成回复建议
* 调用 wingman/draft API,展示 AI 生成的回复草稿
*/
async function handleGenerateDraft(): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
loadingDraft.value = true
try {
const result: DraftResult = await generateDraft(convId)
wingmanResult.value = {
type: 'draft',
title: '🤖 AI 回复建议',
data: result,
}
ElMessage.success('回复建议已生成')
} catch (error: any) {
console.error('生成回复建议失败:', error)
ElMessage.error(error?.message || '生成回复建议失败')
} finally {
loadingDraft.value = false
}
}
/**
* Wingman 触发 - 获取知识库引用
* 调用 wingman/summary API,展示结构化摘要作为知识库引用
*/
async function handleGetKnowledge(): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
loadingKnowledge.value = true
try {
const result: SummaryResult = await generateSummary(convId)
wingmanResult.value = {
type: 'knowledge',
title: '📚 知识库引用',
data: result,
}
ElMessage.success('知识库引用已获取')
} catch (error: any) {
console.error('获取知识库引用失败:', error)
ElMessage.error(error?.message || '获取知识库引用失败')
} finally {
loadingKnowledge.value = false
}
}
/**
* Wingman 触发 - 推荐排查流程
* 调用 wingman/tags API,按分类推荐排查步骤
*/
async function handleRecommendFlow(): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
loadingFlow.value = true
try {
const result: TagsResult = await suggestTags(convId)
wingmanResult.value = {
type: 'flow',
title: '🔍 排查流程推荐',
data: result,
}
ElMessage.success('排查流程已推荐')
} catch (error: any) {
console.error('推荐排查流程失败:', error)
ElMessage.error(error?.message || '推荐排查流程失败')
} finally {
loadingFlow.value = false
}
}
/**
* 清除 Wingman 结果展示
*/
function clearWingmanResult(): void {
wingmanResult.value = null
}
/**
* 采纳草稿到输入框
*/
function adoptDraft(content: string): void {
conversationStore.pendingReplyText = content
ElMessage.success('已填入输入框')
}
/**
* 编辑草稿后发送(与 adopt 相同行为,填入输入框让坐席编辑)
*/
function editDraft(content: string): void {
conversationStore.pendingReplyText = content
ElMessage.success('已填入输入框,可编辑后发送')
}
/**
* 复制知识库内容到剪贴板
*/
async function copyKnowledgeToClipboard(): Promise<void> {
if (!wingmanResult.value || wingmanResult.value.type !== 'knowledge') return
const data = wingmanResult.value.data as SummaryResult
const text = `问题描述:${data.problem}\n原因分析:${data.cause}\n解决方案:${data.solution}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动复制')
}
}
// ============================================================================
// AI 推荐区方法
// ============================================================================
/**
* 处理 AI 推荐卡片的选择
* 将推荐内容填充到对话输入框
*
* @param content - 推荐内容
*/
function handleSelectRecommendation(content: string): void {
conversationStore.pendingReplyText = content
@@ -125,9 +551,6 @@ function handleSelectRecommendation(content: string): void {
/**
* 处理快速回复模板的"使用"事件
* 将模板内容填充到对话输入框
*
* @param content - 模板内容(已替换变量)
*/
function handleUseTemplate(content: string): void {
conversationStore.pendingReplyText = content
@@ -135,7 +558,6 @@ function handleUseTemplate(content: string): void {
/**
* 加载 AI 推荐数据
* 优先从草稿缓存获取,否则使用 Mock 数据
*/
function loadRecommendations(): void {
const fromDraft = loadRecommendationsFromDraft.value
@@ -144,7 +566,6 @@ function loadRecommendations(): void {
return
}
// Mock 数据:当无真实 AI 推荐时展示示例
if (conversationStore.currentConversationId) {
recommendations.value = [
{
@@ -163,12 +584,145 @@ function loadRecommendations(): void {
}
}
// ============================================================================
// 标签区方法
// ============================================================================
/**
* 加载当前会话的标签
*/
function loadCurrentTags(): void {
const conversation = conversationStore.currentConversation
if (conversation && conversation.tags) {
currentTags.value = conversation.tags as ConversationTags
} else {
currentTags.value = {}
}
suggestedTagList.value = []
}
/**
* 获取 AI 标签建议
*/
async function handleSuggestTags(): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
loadingTagSuggestions.value = true
try {
const result = await suggestTags(convId)
if (result && result.suggested_tags) {
suggestedTagList.value = result.suggested_tags.filter(
(tag: string) => !Object.keys(currentTags.value).includes(tag)
)
if (suggestedTagList.value.length === 0) {
ElMessage.info('暂无新的标签建议')
}
}
} catch (error) {
console.error('获取标签建议失败:', error)
ElMessage.error('获取标签建议失败')
} finally {
loadingTagSuggestions.value = false
}
}
/**
* 添加 AI 建议的标签
*/
async function handleAddSuggestedTag(tag: string): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) return
currentTags.value[tag] = true
suggestedTagList.value = suggestedTagList.value.filter((t) => t !== tag)
try {
await saveConversationTags(convId, currentTags.value)
ElMessage.success(`已添加标签: ${tag}`)
} catch (error) {
console.error('保存标签失败:', error)
ElMessage.error('保存标签失败')
delete currentTags.value[tag]
}
}
/**
* 移除已保存的标签
*/
async function handleRemoveTag(key: string): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) return
const oldValue = currentTags.value[key]
delete currentTags.value[key]
try {
await saveConversationTags(convId, currentTags.value)
ElMessage.success(`已移除标签: ${key}`)
} catch (error) {
console.error('保存标签失败:', error)
ElMessage.error('保存标签失败')
currentTags.value[key] = oldValue
}
}
/**
* 手动添加标签
*/
async function handleAddManualTag(): Promise<void> {
const convId = conversationStore.currentConversationId
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
const key = newTagKey.value.trim()
const value = newTagValue.value.trim()
if (!key) {
ElMessage.warning('请输入标签名')
return
}
currentTags.value[key] = value || true
newTagKey.value = ''
newTagValue.value = ''
try {
await saveConversationTags(convId, currentTags.value)
ElMessage.success(`已添加标签: ${key}`)
} catch (error) {
console.error('保存标签失败:', error)
ElMessage.error('保存标签失败')
delete currentTags.value[key]
}
}
// ============================================================================
// 监听
// ============================================================================
/** 监听当前会话变化,重新加载数据和清除 Wingman 结果 */
watch(
() => conversationStore.currentConversationId,
() => {
loadRecommendations()
loadCurrentTags()
clearWingmanResult()
}
)
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
loadRecommendations()
loadCurrentTags()
})
</script>
@@ -180,6 +734,199 @@ onMounted(() => {
overflow: hidden;
}
/* ---- Wingman 触发按钮组 ---- */
.wingman-triggers {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
}
.wingman-btn {
flex: 1;
min-width: 0;
font-size: 12px;
white-space: nowrap;
}
/* ---- Wingman 结果展示区 ---- */
.wingman-result-section {
flex-shrink: 0;
padding: 8px 12px;
max-height: 200px;
overflow-y: auto;
}
.wingman-result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.wingman-result-bar {
width: 3px;
height: 12px;
background-color: #07C160;
border-radius: 2px;
flex-shrink: 0;
}
.wingman-result-title {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
flex: 1;
}
/* ---- 回复草稿卡片 ---- */
.wingman-draft-card {
background: rgba(7, 193, 96, 0.04);
border: 1px solid rgba(7, 193, 96, 0.15);
border-radius: 8px;
padding: 10px;
}
.draft-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 8px;
}
.draft-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.draft-reasoning {
font-size: 11px;
color: var(--text-tertiary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.draft-actions {
display: flex;
gap: 8px;
}
/* ---- 知识库引用卡片 ---- */
.wingman-knowledge-card {
background: rgba(64, 158, 255, 0.04);
border: 1px solid rgba(64, 158, 255, 0.15);
border-radius: 8px;
padding: 10px;
}
.knowledge-section {
margin-bottom: 8px;
}
.knowledge-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.knowledge-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.knowledge-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* ---- 排查流程推荐卡片 ---- */
.wingman-flow-card {
background: rgba(245, 158, 11, 0.04);
border: 1px solid rgba(245, 158, 11, 0.15);
border-radius: 8px;
padding: 10px;
}
.flow-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.flow-icon {
font-size: 16px;
}
.flow-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.flow-priority {
margin-bottom: 8px;
}
.flow-steps {
display: flex;
flex-direction: column;
gap: 6px;
}
.flow-step-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.flow-step-num {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(7, 193, 96, 0.1);
color: #07C160;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.flow-step-label {
flex: 1;
line-height: 1.4;
}
.flow-empty {
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
padding: 8px 0;
}
/* ---- 分隔线 ---- */
.ai-section-divider {
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* ---- 上方 AI 推荐区 ---- */
.ai-recommend-section {
flex: 0 0 auto;
@@ -225,12 +972,84 @@ onMounted(() => {
font-size: 13px;
}
/* ---- 分隔线 ---- */
.ai-section-divider {
border-bottom: 1px solid var(--border-color);
/* ---- 智能标注区 ---- */
.ai-tags-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px 12px;
gap: 8px;
}
.ai-tags-header {
display: flex;
align-items: center;
gap: 8px;
}
.ai-tags-bar {
width: 3px;
height: 12px;
background-color: var(--accent);
border-radius: 2px;
flex-shrink: 0;
}
.ai-tags-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
flex: 1;
}
.ai-tags-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 100px;
overflow-y: auto;
}
.tags-saved {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tags-suggested {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.tags-suggested-title {
font-size: 12px;
color: var(--text-tertiary);
}
.tag-item {
cursor: pointer;
}
.tag-suggested {
border-style: dashed;
}
.ai-tags-empty {
text-align: center;
padding: 8px;
color: var(--text-tertiary);
font-size: 12px;
}
.ai-tags-add {
display: flex;
gap: 6px;
align-items: center;
}
/* ---- 下方快速回复区 ---- */
.quick-reply-section {
flex: 1;
@@ -0,0 +1,120 @@
// =============================================================================
// 企微IT智能服务台 — H5用户端排查模板 API 调用模块(v5.4 增强)
// =============================================================================
// 说明:封装与排查模板相关的所有 HTTP 请求
// 对应后端 API/api/troubleshooting-templates
// 包括:获取模板列表、获取模板详情
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 排查步骤路径节点 */
export interface PathStep {
/** 节点唯一标识(用于进度追踪) */
nodeId?: string
/** 步骤标题 */
label: string
/** 步骤状态: done / current / pending */
status: 'done' | 'current' | 'pending'
}
/** 决策树递归节点 */
export interface FlowchartNode {
/** 节点唯一标识 */
id: string
/** 节点类型: step / decision */
type: 'step' | 'decision'
/** 节点标签文字 */
label: string
/** 节点状态: done / current / pending */
status?: 'done' | 'current' | 'pending'
/** 子步骤列表(step 类型的操作明细) */
children?: FlowchartNode[]
/** "是" 分支(decision 类型)/ 下一步(step 类型) */
yes_branch?: FlowchartNode
/** "否" 分支(decision 类型) */
no_branch?: FlowchartNode
}
/** 排查模板对象(对应后端 TroubleshootingTemplateResponse */
export interface TroubleshootingTemplate {
/** 模板唯一标识 */
id: string
/** 模板名称 */
name: string
/** 分类: vpn/email/system/account */
category: string
/** 排障步骤路径 */
path_steps: PathStep[]
/** 流程图定义 */
flowchart: FlowchartNode
/** 是否启用 */
is_active: boolean
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 排查模板列表响应 */
export interface TroubleshootingTemplateListData {
/** 模板列表 */
items: TroubleshootingTemplate[]
/** 总数 */
total: number
}
/** 查询参数 */
export interface TroubleshootingTemplateQuery {
/** 按分类过滤(可选) */
category?: string
/** 页码(从1开始) */
page?: number
/** 每页数量 */
page_size?: number
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取排查模板列表
* 支持按分类过滤和分页
*
* @param params - 查询参数
* @param params.category - 按分类过滤(可选)
* @param params.page - 页码(可选,默认 1
* @param params.page_size - 每页数量(可选,默认 20)
* @returns 排查模板列表数据
*/
export async function getTroubleshootingTemplates(
params?: TroubleshootingTemplateQuery,
): Promise<TroubleshootingTemplateListData> {
const response: AxiosResponse = await apiClient.get('/troubleshooting-templates', {
params: {
category: params?.category || undefined,
page: params?.page || 1,
page_size: params?.page_size || 20,
},
})
return response.data.data
}
/**
* 获取排查模板详情
*
* @param id - 模板ID
* @returns 排查模板详情
*/
export async function getTroubleshootingTemplate(
id: string,
): Promise<TroubleshootingTemplate> {
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
return response.data.data
}
@@ -0,0 +1,825 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端排查模板详情页面(v5.4 增强)
// =============================================================================
// 说明:C-T5 流程图 H5 端展示
// 功能:
// 1. 展示流程图详情(树形结构)
// 2. 用户可以按照流程图自助排查
// 3. 点击决策节点的选择项来推进流程
// 4. 路径进度条正确反映排查进度(nodeId 追踪)
// 5. "联系IT客服" 按钮始终可见,便于随时切换人工支持
// ============================================================================= -->
<template>
<div class="troubleshooting-detail">
<!-- 顶部导航 -->
<div class="troubleshooting-detail__header">
<van-nav-bar
:title="templateName"
left-arrow
@click-left="goBack"
>
<template #right>
<van-icon
name="service-o"
size="20"
color="#07C160"
@click="goToChat"
class="header-contact-btn"
/>
</template>
</van-nav-bar>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="troubleshooting-detail__loading">
<van-loading type="spinner" color="#07C160" size="32" />
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="errorMsg" class="troubleshooting-detail__error">
<van-empty :description="errorMsg">
<van-button type="primary" size="small" @click="loadDetail">
重试
</van-button>
</van-empty>
</div>
<!-- 流程图内容 -->
<div v-else-if="template" class="troubleshooting-detail__content">
<!-- 路径进度条 -->
<div class="flow-progress" v-if="pathSteps.length > 0">
<div class="flow-progress__bar">
<template v-for="(step, index) in pathSteps" :key="step.nodeId || index">
<div
class="flow-progress__step"
:class="`flow-progress__step--${step.status}`"
>
<span
class="flow-progress__dot"
:class="`flow-progress__dot--${step.status}`"
>
<template v-if="step.status === 'done'"></template>
<template v-else>{{ index + 1 }}</template>
</span>
<span class="flow-progress__label">{{ step.label }}</span>
</div>
<span
v-if="index < pathSteps.length - 1"
class="flow-progress__arrow"
:class="{ 'flow-progress__arrow--active': step.status === 'done' }"
></span>
</template>
</div>
</div>
<!-- 当前节点展示 -->
<div class="flow-content">
<!-- 决策节点 -->
<template v-if="currentNode && currentNode.type === 'decision'">
<div class="flow-question">
<div class="flow-question__header">
<span class="flow-question__icon"></span>
<span class="flow-question__text">{{ currentNode.label }}</span>
</div>
<div class="flow-question__options">
<button
class="flow-option flow-option--yes"
@click="handleSelect('yes')"
:disabled="submitting"
>
<span class="flow-option__icon"></span>
<span>{{ currentNode.yes_branch?.label || '是' }}</span>
</button>
<button
class="flow-option flow-option--no"
@click="handleSelect('no')"
:disabled="submitting"
>
<span class="flow-option__icon"></span>
<span>{{ currentNode.no_branch?.label || '否' }}</span>
</button>
</div>
</div>
</template>
<!-- 步骤节点 -->
<template v-else-if="currentNode && currentNode.type === 'step'">
<div class="flow-instruction">
<div class="flow-instruction__header">
<span class="flow-instruction__icon">📋</span>
<span class="flow-instruction__title">{{ currentNode.label }}</span>
</div>
<!-- 子步骤明细 -->
<div v-if="currentNode.children && currentNode.children.length > 0" class="flow-sub-steps">
<div
v-for="(child, idx) in currentNode.children"
:key="child.id"
class="flow-sub-step"
:class="`flow-sub-step--${child.status || 'pending'}`"
>
<span class="flow-sub-step__dot">{{ idx + 1 }}</span>
<span class="flow-sub-step__label">{{ child.label }}</span>
</div>
</div>
<!-- 操作提示 -->
<div class="flow-instruction__hint">
<span>💡 按步骤操作完成后点击下方按钮继续</span>
</div>
<!-- 继续按钮 -->
<van-button
type="primary"
block
size="large"
@click="handleContinue"
:loading="submitting"
>
完成此步骤继续下一步
</van-button>
</div>
</template>
<!-- 流程结束 -->
<template v-else-if="isCompleted">
<div class="flow-complete">
<div class="flow-complete__icon">🎉</div>
<h3 class="flow-complete__title">排查完成</h3>
<p class="flow-complete__desc">
如果问题仍未解决建议您联系IT支持获取人工帮助
</p>
<van-button type="primary" block size="large" @click="goToChat">
📞 联系IT客服
</van-button>
<van-button
plain
block
size="small"
style="margin-top: 10px"
@click="resetFlow"
>
🔄 重新排查
</van-button>
</div>
</template>
<!-- 初始状态 -->
<template v-else>
<div class="flow-start">
<div class="flow-start__icon">🚀</div>
<h3 class="flow-start__title">开始排查</h3>
<p class="flow-start__desc">
{{ template.name }} - 按照流程指引逐步排查问题
</p>
<van-button type="primary" block size="large" @click="startFlow">
开始排查
</van-button>
<van-button
plain
block
size="small"
style="margin-top: 10px"
@click="goToChat"
>
💬 直接联系IT客服
</van-button>
</div>
</template>
</div>
<!-- 排查中底部联系客服浮动入口 -->
<div v-if="started && !isCompleted" class="flow-contact-fab">
<van-button
type="default"
size="small"
icon="service-o"
round
@click="goToChat"
>
联系IT客服
</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import {
getTroubleshootingTemplate,
type TroubleshootingTemplate,
type FlowchartNode,
type PathStep,
} from '@/api/troubleshooting-templates'
const router = useRouter()
const route = useRoute()
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
/** 模板详情 */
const template = ref<TroubleshootingTemplate | null>(null)
/** 模板名称 */
const templateName = computed(() => (route.query.name as string) || '排查详情')
/** 加载状态 */
const loading = ref(true)
/** 错误信息 */
const errorMsg = ref('')
/** 提交状态(防重复点击) */
const submitting = ref(false)
/** 路径步骤 */
const pathSteps = ref<PathStep[]>([])
/** 当前节点 */
const currentNode = ref<FlowchartNode | null>(null)
/** 是否已开始 */
const started = ref(false)
/** 已访问节点 ID 列表(用于进度追踪) */
const visitedNodeIds = ref<Set<string>>(new Set())
// --------------------------------------------------------------------------
// 计算属性
// --------------------------------------------------------------------------
/** 流程是否完成 */
const isCompleted = computed(() => {
return started.value && !currentNode.value
})
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 返回上一页
*/
function goBack(): void {
if (started.value && !isCompleted.value) {
// 如果排查进行中,返回前提示
router.back()
} else {
router.back()
}
}
/**
* 加载模板详情
*/
async function loadDetail(): Promise<void> {
loading.value = true
errorMsg.value = ''
try {
const id = route.params.id as string
const data = await getTroubleshootingTemplate(id)
template.value = data
// 从模板的流程图预计算路径步骤
if (data.flowchart) {
pathSteps.value = buildPathSteps(data.flowchart)
}
} catch (error: any) {
console.error('[TroubleshootingDetail] 加载失败:', error)
errorMsg.value = error?.message || '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
/**
* 从流程图根节点递归构建路径步骤列表
* 遍历所有决策节点和步骤节点(深度优先),排除子步骤
*/
function buildPathSteps(root: FlowchartNode): PathStep[] {
const steps: PathStep[] = []
function walk(node: FlowchartNode): void {
if (!node) return
// 只收集 decision 和 step 类型的主节点,子步骤通过 children 展示
if (node.type === 'decision' || node.type === 'step') {
steps.push({
nodeId: node.id,
label: node.label,
status: 'pending' as const,
})
}
// 对 decision 节点,递归处理两个分支
if (node.type === 'decision') {
if (node.yes_branch) walk(node.yes_branch)
if (node.no_branch) walk(node.no_branch)
}
// 对 step 节点,按 yes_branch 作为下一个节点
if (node.type === 'step' && node.yes_branch) {
walk(node.yes_branch)
}
}
walk(root)
// 标记第一个步骤为 current
if (steps.length > 0) {
steps[0].status = 'current'
}
return steps
}
/**
* 开始排查流程
*/
function startFlow(): void {
if (!template.value?.flowchart) return
started.value = true
visitedNodeIds.value = new Set<string>()
currentNode.value = template.value.flowchart
currentNode.value.status = 'current'
visitedNodeIds.value.add(currentNode.value.id)
// 更新路径步骤:第一个标记为 current
updatePathStepsForNode(currentNode.value.id)
}
/**
* 处理决策节点选择
*/
function handleSelect(option: 'yes' | 'no'): void {
if (!currentNode.value || submitting.value) return
submitting.value = true
try {
// 标记当前节点为已完成
currentNode.value.status = 'done'
const currentId = currentNode.value.id
// 获取下一个节点
const nextNode: FlowchartNode | undefined =
option === 'yes'
? currentNode.value.yes_branch
: currentNode.value.no_branch
if (nextNode) {
nextNode.status = 'current'
visitedNodeIds.value.add(nextNode.id)
currentNode.value = nextNode
updatePathStepsForNode(nextNode.id)
} else {
// 分支无下一个节点,流程结束
currentNode.value = null
// 所有步骤标记完成
pathSteps.value = pathSteps.value.map(step => ({ ...step, status: 'done' as const }))
}
showToast('已记录您的选择')
} finally {
submitting.value = false
}
}
/**
* 继续下一步(步骤节点完成)
* 步骤节点完成后,通过 yes_branch 推进到下一个节点
*/
function handleContinue(): void {
if (!currentNode.value || submitting.value) return
submitting.value = true
try {
// 标记当前节点为已完成
currentNode.value.status = 'done'
// 也标记所有子步骤为完成(如果有)
if (currentNode.value.children) {
for (const child of currentNode.value.children) {
child.status = 'done'
}
}
// 步骤节点的 yes_branch 指向下一个主节点
const nextNode: FlowchartNode | undefined = currentNode.value.yes_branch
if (nextNode) {
nextNode.status = 'current'
visitedNodeIds.value.add(nextNode.id)
currentNode.value = nextNode
updatePathStepsForNode(nextNode.id)
showToast('已进入下一步')
} else {
// 没有下一个节点,流程结束
currentNode.value = null
pathSteps.value = pathSteps.value.map(step => ({ ...step, status: 'done' as const }))
showToast('排查流程已完成')
}
} finally {
submitting.value = false
}
}
/**
* 根据当前节点 ID 更新路径步骤状态
* 使用 nodeId 精确匹配,避免 label 冲突
*/
function updatePathStepsForNode(currentNodeId: string): void {
const matchedIndex = pathSteps.value.findIndex(s => s.nodeId === currentNodeId)
if (matchedIndex < 0) {
// 当前节点不在预计算的路径步骤中(可能是嵌套子步骤等)
// 不做更新,保持现有步骤状态
return
}
pathSteps.value = pathSteps.value.map((step, i) => ({
...step,
status:
i < matchedIndex
? ('done' as const)
: i === matchedIndex
? ('current' as const)
: ('pending' as const),
}))
}
/**
* 重置排查流程
*/
function resetFlow(): void {
started.value = false
currentNode.value = null
visitedNodeIds.value = new Set()
// 重新构建路径步骤
if (template.value?.flowchart) {
pathSteps.value = buildPathSteps(template.value.flowchart)
}
}
/**
* 跳转到聊天页面联系客服
*/
function goToChat(): void {
router.push({ name: 'ChatView' })
}
// --------------------------------------------------------------------------
// 生命周期
// --------------------------------------------------------------------------
onMounted(() => {
loadDetail()
})
</script>
<style scoped>
/* ===== 主容器 ===== */
.troubleshooting-detail {
min-height: 100vh;
background: var(--bg-secondary);
padding-bottom: 80px;
}
/* ===== 顶部导航 ===== */
.troubleshooting-detail__header {
background: var(--bg-primary);
position: sticky;
top: 0;
z-index: 10;
}
.header-contact-btn {
cursor: pointer;
padding: 4px;
}
/* ===== 加载状态 ===== */
.troubleshooting-detail__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
}
/* ===== 错误状态 ===== */
.troubleshooting-detail__error {
padding: 60px 0;
}
/* ===== 内容区域 ===== */
.troubleshooting-detail__content {
padding: 12px;
}
/* ===== 路径进度条 ===== */
.flow-progress {
background: var(--bg-primary);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
}
.flow-progress__bar {
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
padding-bottom: 4px;
}
.flow-progress__step {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.flow-progress__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;
}
.flow-progress__dot--done {
background: #22c55e;
color: #fff;
}
.flow-progress__dot--current {
background: #07C160;
color: #fff;
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.25);
}
.flow-progress__dot--pending {
background: var(--bg-tertiary);
color: var(--text-tertiary);
border: 1px solid var(--border-color);
}
.flow-progress__label {
font-size: 11px;
color: var(--text-secondary);
}
.flow-progress__step--done .flow-progress__label {
color: var(--text-tertiary);
text-decoration: line-through;
}
.flow-progress__step--current .flow-progress__label {
color: #07C160;
font-weight: 600;
}
.flow-progress__arrow {
color: var(--text-tertiary);
font-size: 10px;
flex-shrink: 0;
padding: 0 4px;
}
.flow-progress__arrow--active {
color: #22c55e;
}
/* ===== 流程内容卡片 ===== */
.flow-content {
background: var(--bg-primary);
border-radius: 12px;
padding: 16px;
}
/* ===== 决策节点 ===== */
.flow-question {
padding: 8px 0;
}
.flow-question__header {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.flow-question__icon {
font-size: 20px;
flex-shrink: 0;
}
.flow-question__text {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
}
.flow-question__options {
display: flex;
gap: 12px;
margin-top: 4px;
}
.flow-option {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 16px;
border-radius: 12px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.flow-option:active:not(:disabled) {
transform: scale(0.97);
}
.flow-option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.flow-option--yes {
border-color: rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.08);
}
.flow-option--yes:active:not(:disabled) {
background: rgba(34, 197, 94, 0.2);
}
.flow-option--no {
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.08);
}
.flow-option--no:active:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
}
.flow-option__icon {
font-size: 18px;
}
/* ===== 步骤节点 ===== */
.flow-instruction {
padding: 8px 0;
}
.flow-instruction__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.flow-instruction__icon {
font-size: 18px;
flex-shrink: 0;
}
.flow-instruction__title {
font-size: 15px;
font-weight: 600;
color: #07C160;
}
.flow-sub-steps {
display: flex;
flex-direction: column;
gap: 8px;
margin: 14px 0;
}
.flow-sub-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.flow-sub-step__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);
}
.flow-sub-step--done .flow-sub-step__dot {
background: #22c55e;
color: #fff;
}
.flow-sub-step--done .flow-sub-step__label {
color: var(--text-tertiary);
text-decoration: line-through;
}
.flow-sub-step--current .flow-sub-step__dot {
background: #07C160;
color: #fff;
}
.flow-sub-step--current .flow-sub-step__label {
color: #07C160;
font-weight: 600;
}
.flow-sub-step__label {
color: var(--text-secondary);
}
.flow-instruction__hint {
margin: 14px 0;
padding: 10px 12px;
background: rgba(7, 193, 96, 0.08);
border-radius: 8px;
font-size: 13px;
color: #07C160;
}
/* ===== 开始 / 完成状态 ===== */
.flow-start,
.flow-complete {
text-align: center;
padding: 30px 20px;
}
.flow-start__icon,
.flow-complete__icon {
font-size: 48px;
margin-bottom: 16px;
}
.flow-start__title,
.flow-complete__title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 10px 0;
}
.flow-start__desc,
.flow-complete__desc {
font-size: 14px;
color: var(--text-tertiary);
margin: 0 0 20px 0;
line-height: 1.5;
}
/* ===== 底部联系客服浮动入口 ===== */
.flow-contact-fab {
position: fixed;
bottom: 24px;
right: 16px;
z-index: 100;
}
.flow-contact-fab :deep(.van-button) {
box-shadow: 0 2px 12px rgba(7, 193, 96, 0.3);
border-color: #07C160;
color: #07C160;
}
</style>
@@ -0,0 +1,406 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端排查模板列表页面(v5.4 增强)
// =============================================================================
// 说明:C-T5 流程图 H5 端展示
// 功能:
// 1. 展示可用的排查模板列表
// 2. 支持按分类筛选
// 3. 支持下拉刷新
// 4. 点击进入流程图详情页
// 5. "联系IT客服" 底部浮动入口
// ============================================================================= -->
<template>
<div class="troubleshooting-list">
<!-- 顶部标题 -->
<div class="troubleshooting-list__header">
<van-nav-bar
title="故障排查"
left-arrow
@click-left="goBack"
>
<template #right>
<van-icon
name="service-o"
size="20"
color="#07C160"
@click="goToChat"
class="header-contact-btn"
/>
</template>
</van-nav-bar>
</div>
<!-- 分类筛选 -->
<div class="troubleshooting-list__filter">
<van-tabs
v-model:active="activeCategory"
@change="onCategoryChange"
:color="'#07C160'"
:title-active-color="'#07C160'"
>
<van-tab title="全部" name="" />
<van-tab title="VPN" name="vpn" />
<van-tab title="邮箱" name="email" />
<van-tab title="系统" name="system" />
<van-tab title="账号" name="account" />
</van-tabs>
</div>
<!-- 下拉刷新容器 -->
<van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
:head-height="80"
pulling-text="下拉刷新"
loosing-text="释放刷新"
loading-text="加载中..."
>
<!-- 加载状态 -->
<div v-if="loading" class="troubleshooting-list__loading">
<van-loading type="spinner" color="#07C160" size="32" />
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="errorMsg" class="troubleshooting-list__error">
<van-empty :description="errorMsg">
<van-button type="primary" size="small" @click="loadTemplates">
重试
</van-button>
</van-empty>
</div>
<!-- 模板列表 -->
<div v-else class="troubleshooting-list__content">
<div class="template-grid">
<div
v-for="template in templates"
:key="template.id"
class="template-card"
@click="goToDetail(template)"
>
<div class="template-card__icon">
{{ getCategoryIcon(template.category) }}
</div>
<div class="template-card__info">
<h3 class="template-card__name">{{ template.name }}</h3>
<p class="template-card__desc">{{ getCategoryDesc(template.category) }}</p>
<div class="template-card__meta">
<span class="template-card__steps">
📋 {{ template.path_steps?.length || 0 }} 个步骤
</span>
</div>
</div>
<div class="template-card__arrow">
<van-icon name="arrow" />
</div>
</div>
</div>
<!-- 空状态 -->
<van-empty
v-if="templates.length === 0 && !loading"
description="暂无排查模板"
image="search"
/>
</div>
</van-pull-refresh>
<!-- 底部联系客服浮动入口 -->
<div class="troubleshooting-contact-fab">
<van-button
type="default"
size="small"
icon="service-o"
round
@click="goToChat"
>
联系IT客服
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getTroubleshootingTemplates,
type TroubleshootingTemplate,
} from '@/api/troubleshooting-templates'
const router = useRouter()
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
/** 当前选中的分类 */
const activeCategory = ref('')
/** 模板列表 */
const templates = ref<TroubleshootingTemplate[]>([])
/** 首次加载状态 */
const loading = ref(true)
/** 下拉刷新状态 */
const refreshing = ref(false)
/** 错误信息 */
const errorMsg = ref('')
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 返回上一页
*/
function goBack(): void {
router.back()
}
/**
* 分类变化时重新加载
*/
function onCategoryChange(): void {
templates.value = []
errorMsg.value = ''
loading.value = true
loadTemplates()
}
/**
* 下拉刷新
*/
async function onRefresh(): Promise<void> {
refreshing.value = true
try {
await loadTemplates()
} finally {
refreshing.value = false
}
}
/**
* 加载模板列表
*/
async function loadTemplates(): Promise<void> {
// 首次加载显示全屏 loading
if (templates.value.length === 0 && !refreshing.value) {
loading.value = true
errorMsg.value = ''
}
try {
const data = await getTroubleshootingTemplates({
category: activeCategory.value || undefined,
})
templates.value = data.items || []
} catch (error: any) {
console.error('[TroubleshootingList] 加载失败:', error)
// 只在无数据时显示错误页
if (templates.value.length === 0) {
errorMsg.value = error?.message || '加载失败,请稍后重试'
}
} finally {
loading.value = false
}
}
/**
* 获取分类图标
*/
function getCategoryIcon(category: string): string {
const iconMap: Record<string, string> = {
vpn: '🔐',
email: '📧',
system: '💻',
account: '👤',
}
return iconMap[category] || '🔧'
}
/**
* 获取分类描述
*/
function getCategoryDesc(category: string): string {
const descMap: Record<string, string> = {
vpn: 'VPN连接异常、无法访问内网',
email: '邮箱登录失败、收发信问题',
system: '系统登录异常、软件故障',
account: '账号锁定、权限申请',
}
return descMap[category] || '常见IT问题排查'
}
/**
* 跳转到模板详情页
*/
function goToDetail(template: TroubleshootingTemplate): void {
router.push({
name: 'TroubleshootingDetail',
params: { id: template.id },
query: { name: template.name },
})
}
/**
* 跳转到聊天页面联系客服
*/
function goToChat(): void {
router.push({ name: 'ChatView' })
}
// --------------------------------------------------------------------------
// 生命周期
// --------------------------------------------------------------------------
onMounted(() => {
loadTemplates()
})
</script>
<style scoped>
/* ===== 主容器 ===== */
.troubleshooting-list {
min-height: 100vh;
background: var(--bg-secondary);
padding-bottom: 80px;
}
/* ===== 顶部标题 ===== */
.troubleshooting-list__header {
background: var(--bg-primary);
}
.header-contact-btn {
cursor: pointer;
padding: 4px;
}
/* ===== 分类筛选 ===== */
.troubleshooting-list__filter {
background: var(--bg-primary);
position: sticky;
top: 0;
z-index: 10;
}
/* ===== 加载状态 ===== */
.troubleshooting-list__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
}
/* ===== 错误状态 ===== */
.troubleshooting-list__error {
padding: 60px 0;
}
/* ===== 内容区域 ===== */
.troubleshooting-list__content {
padding: 12px;
}
/* ===== 模板卡片网格 ===== */
.template-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ===== 模板卡片 ===== */
.template-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg-primary);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: all 0.2s;
}
.template-card:active {
background: var(--bg-secondary);
transform: scale(0.98);
}
.template-card__icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
background: var(--bg-secondary);
border-radius: 10px;
flex-shrink: 0;
}
.template-card__info {
flex: 1;
min-width: 0;
}
.template-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
line-height: 1.4;
}
.template-card__desc {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.template-card__steps {
font-size: 11px;
color: var(--text-quaternary);
}
.template-card__arrow {
color: var(--text-tertiary);
font-size: 16px;
flex-shrink: 0;
}
/* ===== 底部联系客服浮动入口 ===== */
.troubleshooting-contact-fab {
position: fixed;
bottom: 24px;
right: 16px;
z-index: 100;
}
.troubleshooting-contact-fab :deep(.van-button) {
box-shadow: 0 2px 12px rgba(7, 193, 96, 0.3);
border-color: #07C160;
color: #07C160;
}
</style>