Files
wecom_it_smart_desk/frontend-agent/src/components/assistant/QuickReplyPanel.vue
T

747 lines
20 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智能服务台 — 快速回复组件(v5.3 终版 · 三层渐进导航)
// =============================================================================
// 导航结构:L1(7分类网格) → L2(chip子分类) → L3(条目列表) → Enter填入
// 键盘操作:Alt+1~7(L1) → 数字(L2/L3) → Enter填入 → ←/Backspace返回 → /搜索
// ============================================================================= -->
<template>
<div class="qr-panel">
<!-- ================================================================ -->
<!-- 搜索栏置顶 -->
<!-- ================================================================ -->
<div class="qr-search">
<el-input
ref="searchRef"
v-model="searchQuery"
placeholder="搜索快速回复 / Alt+目录数字"
size="small"
clearable
:prefix-icon="SearchIcon"
class="qr-search-input"
@keydown.stop
/>
</div>
<!-- ================================================================ -->
<!-- 面包屑导航 -->
<!-- ================================================================ -->
<div class="qr-breadcrumb">
<span v-if="navState.l1Index >= 0" class="bc-back" @click="goBack">
返回
</span>
<template v-if="navState.l1Index >= 0">
<span
class="bc-item"
:class="{ 'bc-active': navState.l2Index < 0 }"
@click="resetToL1"
>{{ catName(navState.l1Index) }}</span>
<template v-if="navState.l2Index >= 0">
<span class="bc-sep"></span>
<span class="bc-item bc-active">{{ subName(navState.l1Index, navState.l2Index) }}</span>
</template>
</template>
<span class="bc-placeholder" v-else>选择一个分类开始浏览</span>
</div>
<!-- ================================================================ -->
<!-- L1 一级分类7列网格按钮上下排列强制一行 -->
<!-- ================================================================ -->
<div v-show="showL1" class="qr-l1-grid">
<button
v-for="(cat, i) in qrData"
:key="i"
class="qr-l1-btn"
:class="{ active: navState.l1Index === i }"
@click="selectL1(i)"
>
<span class="l1-num">{{ i + 1 }}</span>
<span class="l1-name">{{ cat.name }}</span>
</button>
</div>
<!-- ================================================================ -->
<!-- L2 二级子分类chip 横向流式 -->
<!-- ================================================================ -->
<div v-show="showL2" class="qr-l2-row">
<button
v-for="(sub, i) in currentSubs"
:key="i"
class="qr-l2-chip"
:class="{ selected: navState.l2Index === i }"
@click="selectL2(i)"
>
<span class="l2-num">{{ i + 1 }}</span>
<span>{{ sub.name }}</span>
</button>
</div>
<!-- ================================================================ -->
<!-- L3 回复列表 -->
<!-- ================================================================ -->
<div v-show="showL3" class="qr-l3-scroll">
<div v-if="filteredItems.length === 0" class="qr-empty">
{{ searchQuery ? '无匹配结果' : '暂无回复模板' }}
</div>
<div v-else class="qr-l3-list">
<div
v-for="(item, i) in filteredItems"
:key="i"
class="qr-l3-item"
:class="{ selected: navState.selectedIndex === i }"
:ref="(el) => { if (el) itemRefs[i] = el as HTMLElement }"
@click="selectL3(i)"
@mouseenter="navState.selectedIndex = i"
>
<span class="qr-l3-num">{{ i + 1 }}</span>
<div class="qr-l3-body">
<div class="qr-l3-title">{{ item.title }}</div>
<div class="qr-l3-content">{{ item.content }}</div>
</div>
</div>
</div>
</div>
<!-- ================================================================ -->
<!-- 选中预览条 -->
<!-- ================================================================ -->
<div v-if="selectedPreview" class="qr-selected-bar" @click="fillSelected">
<span class="qr-selected-label">已选</span>
<span class="qr-selected-text">{{ selectedPreview }}</span>
<span class="qr-selected-enter">Enter 填入</span>
</div>
<!-- ================================================================ -->
<!-- 底部键盘指南 -->
<!-- ================================================================ -->
<div class="qr-keyboard-guide">
<span><kbd>Alt+1-7</kbd> 一级</span>
<span class="qr-guide-sep">|</span>
<span><kbd>数字</kbd> 选子项</span>
<span class="qr-guide-sep">|</span>
<span><kbd>Enter</kbd> 填入</span>
<span class="qr-guide-sep">|</span>
<span><kbd></kbd> 返回</span>
<span class="qr-guide-sep">|</span>
<span><kbd>/</kbd> 搜索</span>
</div>
</div>
</template>
<!-- ==================================================================== -->
<!-- Script -->
<!-- ==================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import { useConversationStore } from '@/stores/conversation'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { qrData, type QrCategory, type QrItem } from '@/data/qrData'
// ========================================================================
// Interface
// ========================================================================
interface Emits {
(e: 'use-template', content: string): void
}
const emit = defineEmits<Emits>()
// ========================================================================
// State
// ========================================================================
const conversationStore = useConversationStore()
const searchRef = ref<InstanceType<typeof import('element-plus')['ElInput']> | null>(null)
const itemRefs = ref<Record<number, HTMLElement>>({})
/** 搜索关键词 */
const searchQuery = ref('')
/**
* 导航状态机
* l1Index: -1 = 初始/L1选择中; >=0 = 已选L1分类
* l2Index: -1 = L2选择中; >=0 = 已选L2子分类,展示L3
* selectedIndex: L3列表中的选中索引
*/
interface NavState {
l1Index: number
l2Index: number
selectedIndex: number
}
const navState = ref<NavState>({
l1Index: -1,
l2Index: -1,
selectedIndex: 0,
})
// ========================================================================
// Computed
// ========================================================================
/** 是否显示 L1 网格 */
const showL1 = computed(() => navState.value.l1Index < 0)
/** 是否显示 L2 chip 行 */
const showL2 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index < 0)
/** 是否显示 L3 列表 */
const showL3 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index >= 0)
/** 当前 L1 分类 */
const currentCategory = computed<QrCategory | null>(() => {
if (navState.value.l1Index < 0) return null
return qrData[navState.value.l1Index] ?? null
})
/** 当前 L2 子分类列表 */
const currentSubs = computed(() => {
const cat = currentCategory.value
return cat ? cat.subs : []
})
/** 当前 L3 条目列表 */
const currentItems = computed(() => {
const cat = currentCategory.value
if (!cat || navState.value.l2Index < 0) return []
const sub = cat.subs[navState.value.l2Index]
return sub ? sub.items : []
})
/** 搜索过滤后的 L3 条目 */
const filteredItems = computed<QrItem[]>(() => {
const items = currentItems.value
if (!searchQuery.value.trim()) return items
const q = searchQuery.value.trim().toLowerCase()
return items.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.content.toLowerCase().includes(q)
)
})
/** 当前选中的条目文案预览 */
const selectedPreview = computed(() => {
if (!showL3.value) return null
const items = filteredItems.value
if (items.length === 0) return null
const idx = navState.value.selectedIndex
if (idx < 0 || idx >= items.length) return null
return items[idx].title
})
// ========================================================================
// Helpers
// ========================================================================
function catName(index: number): string {
return qrData[index]?.name ?? ''
}
function subName(l1: number, l2: number): string {
return qrData[l1]?.subs[l2]?.name ?? ''
}
// ========================================================================
// Navigation Methods
// ========================================================================
/** 选择 L1 分类 → 进入 L2 */
function selectL1(index: number): void {
if (index < 0 || index >= qrData.length) return
navState.value = { l1Index: index, l2Index: -1, selectedIndex: 0 }
searchQuery.value = ''
}
/** 选择 L2 子分类 → 进入 L3 */
function selectL2(index: number): void {
const cat = currentCategory.value
if (!cat || index < 0 || index >= cat.subs.length) return
navState.value.l2Index = index
navState.value.selectedIndex = 0
searchQuery.value = ''
nextTick(() => scrollToSelected())
}
/** 点击 L3 条目 → 直接填入 */
function selectL3(index: number): void {
const items = filteredItems.value
if (index < 0 || index >= items.length) return
fillContent(items[index].content)
}
/** 将选中条目填入输入框 */
function fillSelected(): void {
const items = filteredItems.value
const idx = navState.value.selectedIndex
if (idx < 0 || idx >= items.length) return
fillContent(items[idx].content)
}
/** 填入内容(含变量替换) */
function fillContent(content: string): void {
const conv = conversationStore.currentConversation
const variables: Record<string, string> = {}
if (conv) {
variables.employee_name = conv.employee_name || ''
variables.department = conv.department || ''
variables.position = conv.position || ''
}
let result = content
for (const [key, value] of Object.entries(variables)) {
result = result.replaceAll(`{${key}}`, value)
}
emit('use-template', result)
}
/** 返回上一级 */
function goBack(): void {
if (navState.value.l2Index >= 0) {
// L3 → L2
navState.value.l2Index = -1
navState.value.selectedIndex = 0
} else if (navState.value.l1Index >= 0) {
// L2 → L1
navState.value.l1Index = -1
navState.value.selectedIndex = 0
}
}
/** 回到 L1(点击面包屑中的 L1) */
function resetToL1(): void {
if (navState.value.l2Index >= 0) {
navState.value.l2Index = -1
navState.value.selectedIndex = 0
}
}
/** 滚动选中项到视图 */
function scrollToSelected(): void {
nextTick(() => {
const el = itemRefs.value[navState.value.selectedIndex]
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
// ========================================================================
// Keyboard Navigation (数字键选择)
// ========================================================================
function handleDigitKey(digit: number): void {
if (showL1.value) {
// L1: 数字键选择分类
if (digit >= 1 && digit <= qrData.length) {
selectL1(digit - 1)
}
} else if (showL2.value) {
// L2: 数字键选择子分类
const subs = currentSubs.value
if (digit >= 1 && digit <= subs.length) {
selectL2(digit - 1)
}
} else if (showL3.value) {
// L3: 数字键选择条目
const items = filteredItems.value
if (digit >= 1 && digit <= items.length) {
selectL3(digit - 1)
}
}
}
function navigateUpDown(direction: 'up' | 'down'): void {
if (showL3.value) {
const len = filteredItems.value.length
if (len === 0) return
if (direction === 'up') {
navState.value.selectedIndex =
navState.value.selectedIndex > 0 ? navState.value.selectedIndex - 1 : len - 1
} else {
navState.value.selectedIndex =
navState.value.selectedIndex < len - 1 ? navState.value.selectedIndex + 1 : 0
}
scrollToSelected()
}
}
function confirmSelection(): void {
if (showL3.value) {
fillSelected()
}
}
function focusSearch(): void {
searchRef.value?.focus()
}
// ========================================================================
// Keyboard Shortcuts Registration
// ========================================================================
// 注册 Alt+1~7 分类切换
function handleCategoryShortcut(index: number): void {
if (index >= 0 && index < qrData.length) {
selectL1(index)
}
}
// 注册全局键盘快捷键
useKeyboardShortcuts({
onQuickReplyCategory: handleCategoryShortcut,
onQuickReplyDigit: handleDigitKey,
onQuickReplyBack: goBack,
onQuickReplyNavigate: navigateUpDown,
onQuickReplyConfirm: confirmSelection,
onFocusSearch: focusSearch,
})
// ========================================================================
// Watchers — 搜索词变化后重置选中
// ========================================================================
watch(searchQuery, () => {
navState.value.selectedIndex = 0
})
// ========================================================================
// Lifecycle — 初始进入默认展示 L1
// ========================================================================
onMounted(() => {
navState.value = { l1Index: -1, l2Index: -1, selectedIndex: 0 }
})
</script>
<!-- ==================================================================== -->
<!-- Styles -->
<!-- ==================================================================== -->
<style scoped>
.qr-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- 搜索栏 ---- */
.qr-search {
padding: 6px 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
}
.qr-search-input {
width: 100%;
}
.qr-search-input :deep(.el-input__wrapper) {
background-color: var(--bg-tertiary);
border-radius: var(--radius-md);
box-shadow: none !important;
border: 1px solid var(--border-light);
font-size: 12px;
}
.qr-search-input :deep(.el-input__wrapper:hover) {
border-color: var(--accent);
}
.qr-search-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) !important;
}
.qr-search-input :deep(.el-input__prefix-inner) {
color: var(--text-tertiary);
}
/* ---- 面包屑 ---- */
.qr-breadcrumb {
padding: 5px 10px;
flex-shrink: 0;
font-size: 11px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-light);
background: var(--bg-tertiary);
min-height: 26px;
}
.bc-back {
cursor: pointer;
color: var(--accent);
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
transition: 0.2s;
}
.bc-back:hover {
background: var(--accent-soft);
}
.bc-sep {
color: var(--text-placeholder);
}
.bc-item {
color: var(--text-secondary);
transition: 0.2s;
cursor: default;
}
.bc-item.bc-active {
font-weight: 600;
color: var(--text-primary);
}
.bc-item:not(.bc-active) {
cursor: pointer;
}
.bc-item:not(.bc-active):hover {
color: var(--accent);
}
.bc-placeholder {
color: var(--text-tertiary);
font-style: italic;
}
/* ---- L1 一级分类:7列网格,强制一行,按钮内上下排列 ---- */
.qr-l1-grid {
padding: 4px 6px 6px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.qr-l1-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 5px 2px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: 0.15s;
font-size: 10px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-light);
min-width: 0;
overflow: hidden;
}
.qr-l1-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.qr-l1-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.l1-num {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
border: 1px solid var(--border-light);
}
.qr-l1-btn.active .l1-num {
background: var(--accent);
color: var(--bg-secondary);
border-color: var(--accent);
}
.l1-name {
font-size: 10px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* ---- L2 二级子分类 chip ---- */
.qr-l2-row {
padding: 5px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.qr-l2-chip {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 12px;
cursor: pointer;
transition: 0.15s;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-light);
white-space: nowrap;
}
.qr-l2-chip:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-light);
}
.qr-l2-chip.selected {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.l2-num {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-tertiary);
font-size: 10px;
font-weight: 600;
}
.qr-l2-chip.selected .l2-num {
background: var(--accent);
color: var(--bg-secondary);
}
/* ---- L3 列表 ---- */
.qr-l3-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.qr-empty {
text-align: center;
padding: 24px 12px;
color: var(--text-tertiary);
font-size: 12px;
}
.qr-l3-list {
padding: 2px 0;
}
.qr-l3-item {
display: flex;
gap: 6px;
padding: 7px 10px;
cursor: pointer;
transition: background 0.15s;
border-left: 3px solid transparent;
}
.qr-l3-item:hover {
background: var(--bg-hover);
}
.qr-l3-item.selected {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.qr-l3-num {
flex-shrink: 0;
width: 16px;
text-align: right;
font-size: 10px;
color: var(--text-tertiary);
margin-top: 1px;
}
.qr-l3-body {
flex: 1;
min-width: 0;
overflow: hidden;
}
.qr-l3-title {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qr-l3-content {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
}
/* ---- 选中预览条 ---- */
.qr-selected-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-top: 1px solid var(--border-light);
background: var(--bg-accent-soft);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.qr-selected-bar:hover {
background: var(--bg-hover);
}
.qr-selected-label {
color: var(--text-tertiary);
flex-shrink: 0;
}
.qr-selected-text {
flex: 1;
color: var(--accent);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qr-selected-enter {
color: var(--text-placeholder);
font-size: 10px;
flex-shrink: 0;
}
/* ---- 键盘指南 ---- */
.qr-keyboard-guide {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
border-top: 1px solid var(--border-light);
background: var(--bg-secondary);
flex-shrink: 0;
font-size: 10px;
color: var(--text-tertiary);
flex-wrap: wrap;
}
.qr-keyboard-guide kbd {
display: inline-block;
padding: 0 3px;
font-size: 9px;
font-family: inherit;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 2px;
color: var(--text-secondary);
line-height: 1.4;
}
.qr-guide-sep {
color: var(--text-placeholder);
}
</style>