chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
@@ -0,0 +1,746 @@
<!-- =============================================================================
// 企微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>