Files
wecom_it_smart_desk/archives/2026-08-05-v1.9-handoff/InputBar.vue
T

1524 lines
47 KiB
Vue
Raw Normal View History

<!-- =============================================================================
// 企微IT智能服务台 — H5用户端输入栏组件(v1.3 视觉重塑)
// =============================================================================
// 说明:底部输入栏,固定在消息列表下方。
// v1.3 视觉重塑:
// - 工具栏从 InputBar 内下方 → 移到 InputBar 顶部(resize-handle 之后)
// - 工具栏 class: .input-bar__toolbar → .glass-toolbar(玻璃拟态胶囊)
// - 按钮 class: .input-bar__btn → .glass-btn40px 圆形玻璃)
// - 坐席按钮 class: .input-bar__agent → .agent-btn44px 图片头像 + 紫粉渐变边框)
// - emoji/file/voice 按钮内容:emoji 字符 → 高端 SVG 图标
// - 坐席按钮:删除 .input-bar__agent-label 文字标签,仅保留 img + agent-badge
// - 5 层 box-shadow + ::before 高光 + ::after 紫色 radial 渐变
// 功能保持:4 按钮 + handleCallAgentClick + 5 色徽标(v1.2 契约不变)
// ============================================================================= -->
<template>
<!-- ===========================================================
SVG defs渐变定义emoji / 文件 / 话筒
这些 defs 必须在 button SVG 之前渲染 url(#g-*) 引用
=========================================================== -->
<svg width="0" height="0" style="position: absolute;" aria-hidden="true">
<defs>
<!-- 表情渐变 -->
<linearGradient id="g-emoji" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fde68a" />
<stop offset="60%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#d97706" />
</linearGradient>
<radialGradient id="g-emoji-blush" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(244, 63, 94, 0.4)" />
<stop offset="100%" stop-color="rgba(244, 63, 94, 0)" />
</radialGradient>
<linearGradient id="g-highlight" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.7" />
<stop offset="100%" stop-color="white" stop-opacity="0" />
</linearGradient>
<!-- 文件渐变 -->
<linearGradient id="g-doc-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="#dbeafe" />
</linearGradient>
<linearGradient id="g-doc-fold" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c7d2fe" />
<stop offset="100%" stop-color="#6366f1" />
</linearGradient>
<linearGradient id="g-doc-text" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#a855f7" />
</linearGradient>
<!-- 语音渐变 -->
<linearGradient id="g-mic-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c4b5fd" />
<stop offset="50%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#6d28d9" />
</linearGradient>
<linearGradient id="g-mic-shine" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.65" />
<stop offset="100%" stop-color="white" stop-opacity="0" />
</linearGradient>
<!-- 群聊渐变v1.9 新增 -->
<linearGradient id="g-group" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#a5b4fc" />
<stop offset="100%" stop-color="#c4b5fd" />
</linearGradient>
<linearGradient id="g-group-strong" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="50%" stop-color="#a855f7" />
<stop offset="100%" stop-color="#ec4899" />
</linearGradient>
<!-- 圆角长方形轨道渐变v1.9 新增 -->
<linearGradient id="g-rail" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.5" />
<stop offset="50%" stop-color="#c4b5fd" stop-opacity="0.9" />
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.5" />
</linearGradient>
</defs>
</svg>
<div class="input-bar">
<!-- 拖拽手柄可手动调节输入栏高度 -->
<div
class="input-bar__resize-handle"
@mousedown="handleInputResizeStart"
>
<div class="input-bar__resize-track"></div>
</div>
<!-- ===========================================================
v1.9 圆润拱形工具栏 InputBar 内顶部居中resize-handle 之后
5 按钮顺序emoji / 文件 / 人工坐席(居中 60px) / 语音 / 群聊
拱形轨道 SVG 严格按交付清单 § 固化viewBox 0 0 312 84
=========================================================== -->
<div class="gem-toolbar">
<!-- 拱形轨道背景装饰性 SVG对辅助阅读隐藏 -->
<svg
class="gem-toolbar-bg"
viewBox="0 0 312 84"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
role="presentation"
>
<path
d="M 18 18
L 84 18
C 116 18, 126 6, 156 4
C 186 6, 196 18, 216 18
L 294 18
C 302 18, 312 26, 312 34
L 312 50
C 312 58, 302 66, 294 66
L 216 66
C 196 66, 186 78, 156 80
C 126 78, 116 66, 84 66
L 18 66
C 0 66, 0 58, 0 50
L 0 34
C 0 26, 10 18, 18 18 Z"
fill="url(#g-rail)"
stroke="rgba(255,255,255,0.85)"
stroke-width="1.2"
/>
</svg>
<div class="gem-row">
<!-- 1. 表情按钮高端 SVG emoji 图标 -->
<button
class="glass-btn"
:class="{ 'is-active-emoji': showEmojiPanel }"
title="表情"
aria-label="表情"
@click="handleEmoji"
>
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="url(#g-emoji)" />
<ellipse cx="12" cy="6" rx="6" ry="2.5" fill="url(#g-highlight)" />
<ellipse cx="6.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)" />
<ellipse cx="17.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)" />
<ellipse cx="8.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b" />
<ellipse cx="15.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b" />
<circle cx="8.9" cy="10.4" r="0.5" fill="white" />
<circle cx="15.9" cy="10.4" r="0.5" fill="white" />
<path
d="M8 15 Q12 18 16 15"
fill="none"
stroke="#1e1b4b"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
<!-- 2. 文件按钮高端 SVG 文件图标 -->
<button
class="glass-btn"
title="文件"
aria-label="文件"
@click="handleFile"
>
<svg viewBox="0 0 24 24" fill="none">
<path
d="M4 2 L14 2 L20 8 L20 20 L4 20 Z"
fill="url(#g-doc-body)"
stroke="#818cf8"
stroke-width="0.8"
stroke-linejoin="round"
/>
<path
d="M14 2 L14 8 L20 8"
fill="url(#g-doc-fold)"
stroke="#818cf8"
stroke-width="0.8"
stroke-linejoin="round"
/>
<ellipse cx="10" cy="4" rx="5" ry="0.8" fill="url(#g-highlight)" />
<rect x="7" y="11.5" width="10" height="0.8" rx="0.4" fill="url(#g-doc-text)" />
<rect
x="7"
y="14"
width="8"
height="0.8"
rx="0.4"
fill="url(#g-doc-text)"
opacity="0.7"
/>
<rect
x="7"
y="16.5"
width="6"
height="0.8"
rx="0.4"
fill="url(#g-doc-text)"
opacity="0.5"
/>
</svg>
</button>
<!-- 3. 人工坐席按钮居中60pxv1.9 增大 50%图片头像 + 5 色徽标 -->
<button
class="agent-btn gem"
:class="[agentBtnClass, callAgentBtnClass]"
:title="callAgentBtnTitle"
:aria-label="callAgentBtnTitle"
:disabled="callAgentState === 'disabled'"
@click="handleCallAgentClick"
>
<img
:src="agentAvatar"
class="agent-btn__avatar"
alt="人工坐席"
/>
<span class="agent-badge" :class="agentBadgeClass" aria-hidden="true"></span>
</button>
<!-- 4. 语音按钮三态默认 / 录音 / 已识别 高端 SVG 话筒图标 -->
<button
v-if="showVoiceButton"
class="glass-btn"
:class="voiceBtnClass"
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
:disabled="isTranscribing"
@mousedown.prevent="handleVoiceToggle"
@click.prevent="handleVoiceToggle"
@touchstart.prevent="handleVoiceToggle"
>
<svg viewBox="0 0 24 24" fill="none">
<rect x="8" y="2" width="8" height="13" rx="4" fill="url(#g-mic-body)" />
<rect x="9" y="3" width="2" height="11" rx="1" fill="url(#g-mic-shine)" />
<line
x1="10.5"
y1="6"
x2="10.5"
y2="11"
stroke="white"
stroke-width="0.3"
opacity="0.4"
/>
<line
x1="12"
y1="5"
x2="12"
y2="12"
stroke="white"
stroke-width="0.3"
opacity="0.4"
/>
<line
x1="13.5"
y1="6"
x2="13.5"
y2="11"
stroke="white"
stroke-width="0.3"
opacity="0.4"
/>
<path
d="M3 12 Q3 18 12 18 Q21 18 21 12"
fill="none"
stroke="url(#g-mic-body)"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="12"
y1="18"
x2="12"
y2="22"
stroke="url(#g-mic-body)"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="8"
y1="22"
x2="16"
y2="22"
stroke="url(#g-mic-body)"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<!-- 5. 群聊按钮v1.9 新增3 人小组 + 1 人前景 SVG 图标 -->
<button
class="glass-btn"
title="群聊"
aria-label="群聊"
@click="handleGroupChat"
>
<svg viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="9" r="2.2" fill="url(#g-group)" />
<path d="M5 16 Q5 13 8 13 Q11 13 11 16 Z" fill="url(#g-group)" />
<circle cx="16" cy="9" r="2.2" fill="url(#g-group)" />
<path d="M13 16 Q13 13 16 13 Q19 13 19 16 Z" fill="url(#g-group)" />
<circle cx="12" cy="10.5" r="2.8" fill="url(#g-group-strong)" />
<path d="M8.5 18.5 Q8.5 14 12 14 Q15.5 14 15.5 18.5 Z" fill="url(#g-group-strong)" />
</svg>
</button>
</div>
</div>
<!-- 输入行输入框 + 发送按钮 -->
<div class="input-bar__row">
<!-- 文本输入框 默认3行自适应内容高度 -->
<van-field
ref="inputFieldRef"
v-model="inputText"
class="input-bar__field"
placeholder="请输入消息..."
type="textarea"
:autosize="{ minHeight: 54, maxHeight: 200 }"
rows="3"
:disabled="!store.isLoggedIn"
@keydown.enter="handleEnterKey"
@paste="handlePaste"
/>
<!-- 发送按钮右下角绝对定位 -->
<van-button
class="input-bar__send-btn"
type="primary"
size="small"
:disabled="!canSend"
:loading="store.loading"
@click="handleSend"
>
发送
</van-button>
</div>
<!-- v1.2 表情面板位于工具栏与输入行之间使用 slideUp 动画 -->
<div v-if="showEmojiPanel" class="emoji-panel">
<div class="emoji-panel__grid">
<button
v-for="emoji in commonEmojis"
:key="emoji"
class="emoji-panel__item"
@click="onEmojiClick(emoji)"
>{{ emoji }}</button>
</div>
</div>
<!-- 语音状态提示条百度ASR内联录音/识别时显示 -->
<div v-if="isVoiceActive && voiceStrategy === 'baidu'" class="input-bar__voice-status">
<span v-if="isTranscribing">正在识别语音...</span>
<span v-else>正在语音输入... 点击停止</span>
</div>
<!-- 表情面板遮罩点击外部关闭 -->
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
<!-- 隐藏的文件输入框文件上传用由工具栏按钮触发 -->
<input
ref="fileInputRef"
type="file"
style="display: none"
@change="handleFileSelect"
/>
</div>
</template>
<script setup lang="ts">
/**
* InputBar 输入栏组件(v1.3 视觉重塑)
* 布局:[玻璃拟态工具栏(4 按钮)] [表情面板] [输入行:输入框 + 发送按钮]
* 工具栏:emojiSVG 表情)/ fileSVG 文件)/ voiceSVG 话筒)/ agent(图片头像 + 5 色徽标)
* 坐席按钮:5 色徽标 + handleCallAgentClick 路由 6 态动作
* 输入框固定底部,默认3行可见,高度动态适应
* 顶部拖拽手柄可手动调节输入栏整体高度
* 支持粘贴图片上传(Ctrl+V)和文件选择上传
*/
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { showToast } from 'vant'
import { storeToRefs } from 'pinia'
import { useConversationStore } from '@/stores/conversation'
import { uploadFile } from '@/api/upload'
import { useWecomVoice } from '@/composables/useWecomVoice'
import { useAudioRecorder } from '@/composables/useAudioRecorder'
import { transcribeAudio } from '@/api/voice'
import {
computeCallAgentState,
computeCallAgentBtnText,
computeCallAgentBtnTitle,
type CallAgentState,
} from './inputBarCallAgentState'
import {
computeIntegrationBtnClass,
computeCallAction,
} from './integrationZoneLogic'
// ============================================================================
// 工具函数:安全提取错误详情(防止 [object Object]
// ============================================================================
/**
* 将 error.response.data.detail 安全转换为字符串
* FastAPI 422 时 detail 是数组,直接拼接会变成 "[object Object]"
*/
function formatErrorDetail(detail: any): string {
if (!detail) return ''
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
// FastAPI 验证错误:取每个元素的 msg 和 loc
return detail.map((d: any) => {
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
if (d.msg) return d.msg
return JSON.stringify(d)
}).join('; ')
}
if (typeof detail === 'object') return JSON.stringify(detail)
return String(detail)
}
const store = useConversationStore()
// ============================================================================
// 紧急关键词列表(v1.3 保留 — callAgentState 的依赖项)
// ============================================================================
const URGENT_KEYWORDS: RegExp[] = [
/紧急/i,
/urgent/i,
/崩溃/i,
/无法打开/i,
/登不上/i,
/登录不上/i,
/故障/i,
]
/** 从 store 解构响应式状态(消息/坐席在线/当前会话) */
const { messages, agentOnline, currentConversation } = storeToRefs(store)
/**
* 是否含紧急关键词(computed)
* 扫描所有员工消息,任一命中关键词 → true
*/
const hasUrgentKeywords = computed<boolean>(() =>
messages.value.some(msg =>
msg.message_type === 'employee' &&
URGENT_KEYWORDS.some(re => re.test(msg.content))
)
)
/** 当前会话的 6 态坐席入口状态。 */
const callAgentState = computed<CallAgentState>(() =>
computeCallAgentState({
currentConversation: currentConversation.value,
agentOnline: agentOnline.value,
canReopen: store.canReopen,
canCallAgent: store.canCallAgent,
hasUrgentKeywords: hasUrgentKeywords.value,
})
)
/** 坐席按钮的 6 态 modifier class。 */
const callAgentBtnClass = computed<Record<string, boolean>>(() =>
computeIntegrationBtnClass(callAgentState.value)
)
/** 头像按钮状态 class(用于 5 色徽标及紧急态动画)。 */
const agentBtnClass = computed<Record<string, boolean>>(() => ({
'is-online': callAgentState.value === 'active',
'is-urgent': callAgentState.value === 'urgent',
'is-waiting': callAgentState.value === 'waiting',
'is-offline': callAgentState.value === 'disabled',
'is-end': callAgentState.value === 'end' || callAgentState.value === 'reopen',
}))
/** 坐席头像右下角状态徽标 class。 */
const agentBadgeClass = computed<Record<string, boolean>>(() => ({
'is-online': callAgentState.value === 'active',
'is-urgent': callAgentState.value === 'urgent',
'is-waiting': callAgentState.value === 'waiting',
'is-offline': callAgentState.value === 'disabled',
'is-end': callAgentState.value === 'end' || callAgentState.value === 'reopen',
}))
/** 坐席头像资源(public 目录通过根路径访问)。 */
const agentAvatar = '/avatars/agent.png'
const callAgentBtnTitle = computed<string>(() =>
computeCallAgentBtnTitle(callAgentState.value, agentOnline.value)
)
/** 输入框文本 */
const inputText = ref<string>('')
/** 输入框组件引用(用于获取 DOM 元素) */
const inputFieldRef = ref<any>(null)
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 是否可以发送消息(输入框有内容且未在加载中) */
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn
})
/** 是否正在拖拽调节输入栏高度 */
const isInputResizing = ref<boolean>(false)
/** 表情面板是否可见 */
const showEmojiPanel = ref<boolean>(false)
// ============================================================================
// 语音识别功能(双策略:企微 JS-SDK + 百度ASR
// ============================================================================
const {
state: voiceState,
init: initVoice,
startRecording,
stopAndTranslate,
isSupported: isWecomVoiceSupported,
} = useWecomVoice()
/**
* 语音策略:'wecom'(企微JS-SDK| 'baidu'(百度ASR| 'none'(不支持)
*/
const voiceStrategy = computed<'wecom' | 'baidu' | 'none'>(() => {
if (isWecomVoiceSupported()) return 'wecom'
if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') return 'baidu'
const ua = navigator.userAgent
const isWxwork = /wxwork/i.test(ua)
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(ua)
const isMac = /Macintosh|Mac OS X/i.test(ua)
if (isWxwork && !isMobile && !isMac) return 'baidu'
return 'none'
})
/** 是否显示语音按钮(任一策略可用即显示) */
const showVoiceButton = computed(() => voiceStrategy.value !== 'none')
// 百度ASR 录音控制器
const baiduRecorder = useAudioRecorder()
/** 百度ASR 是否正在录音(响应式,绑定到模板) */
const isBaiduRecording = computed(() => baiduRecorder.isRecording.value)
/** 百度ASR 是否正在识别中(录音停止后、上传后端识别期间) */
const isTranscribing = ref<boolean>(false)
/**
* 🆕 v1.2:语音识别成功短暂显示状态(1.5s 后自动回默认)
* 录音停止 → 后端识别成功 → 设为 true → 1.5s 后自动回 false
*/
const voiceRecognitionCompleted = ref<boolean>(false)
/**
* 语音按钮状态(default / recording / recognized
* 优先级:recognized > recording > default
*/
const voiceBtnState = computed<'default' | 'recording' | 'recognized'>(() => {
if (voiceRecognitionCompleted.value) return 'recognized'
if (isVoiceActive.value) return 'recording'
return 'default'
})
/** 语音按钮 3 态的 classis-voice-default / is-voice-recording / is-voice-recognized */
const voiceBtnClass = computed<Record<string, boolean>>(() => ({
'is-voice-default': voiceBtnState.value === 'default',
'is-voice-recording': voiceBtnState.value === 'recording',
'is-voice-recognized': voiceBtnState.value === 'recognized',
}))
/**
* 语音是否激活中(录音中或识别中),用于按钮 UI 状态
*/
const isVoiceActive = computed(() => {
if (voiceStrategy.value === 'wecom') return voiceState.isRecording
if (voiceStrategy.value === 'baidu') return isBaiduRecording.value || isTranscribing.value
return false
})
/** 识别结果是否需要追加前缀空格(如果输入框已有内容,加空格分隔) */
function shouldAddSpaceBefore(text: string): boolean {
return text.length > 0 && !text.endsWith(' ') && !text.endsWith('\n')
}
// ============================================================================
// 生命周期:Document 级别粘贴监听(修复 van-field @paste 不触发文件粘贴的问题)
// ============================================================================
onMounted(() => {
document.addEventListener('paste', handleDocPaste)
document.addEventListener('keydown', handleGlobalKeydown)
if (isWecomVoiceSupported()) {
initVoice().catch((err: any) => {
console.warn('[InputBar] 企微 JS-SDK 初始化失败:', err)
})
}
})
onUnmounted(() => {
document.removeEventListener('paste', handleDocPaste)
document.removeEventListener('keydown', handleGlobalKeydown)
if (baiduRecorder.isRecording.value) {
baiduRecorder.stop()
}
})
/**
* Document 级别粘贴处理(备用方案)
* 做什么:捕获 van-field @paste 未处理的文件粘贴
*/
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
const target = event.target as HTMLElement
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
const items = event.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.kind === 'file') {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
} else {
await handleFileUpload(file)
}
return
}
}
}
/**
* 常用表情列表(8行x8列 = 64 个常用表情)
*/
const commonEmojis = [
'😀','😃','😄','😁','😆','😅','🤣','😂',
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
'😚','😙','😋','😛','😜','🤪','😝','🤑',
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
'😶','😏','😒','🙄','😬','😮','🤯','😲',
'😳','🥺','😢','😭','😤','😠','😡','🤬',
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','💯','✅',
]
/**
* 选择表情后插入到输入框
*/
function onEmojiClick(emoji: string): void {
inputText.value += emoji
showEmojiPanel.value = false
nextTick(() => {
const el = inputFieldRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement | null
el?.focus()
})
}
// ============================================================================
// 消息发送
// ============================================================================
/**
* 处理 Enter 键
* 桌面端 Enter 发送,Shift+Enter 换行
* 移动端不拦截 Enter(让系统默认行为处理)
*/
function handleEnterKey(event: KeyboardEvent): void {
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isMobile) return
if (!event.shiftKey) {
event.preventDefault()
handleSend()
}
}
/**
* 发送消息
*/
async function handleSend(): Promise<void> {
const content = inputText.value.trim()
if (!content || store.loading) return
inputText.value = ''
await store.sendNewMessage(content)
}
// ============================================================================
// 文件/图片上传
// ============================================================================
/**
* 处理粘贴事件
*/
async function handlePaste(event: ClipboardEvent): Promise<void> {
const items = event.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.kind === 'file') {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
} else {
await handleFileUpload(file)
}
return
}
}
}
/**
* 上传图片并发送图片消息
*/
async function handleImageUpload(file: File | Blob): Promise<void> {
try {
showToast('图片上传中...')
const result = await uploadFile(file)
const fileName = file instanceof File ? file.name : '截图'
await store.sendNewMessage(`[图片] ${fileName}`, {
msg_type: 'image',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('图片发送成功')
} catch (error: any) {
console.error('图片上传失败:', error)
showToast(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'图片上传失败,请重试'
)
}
}
/**
* 上传文件并发送文件消息
*/
async function handleFileUpload(file: File | Blob): Promise<void> {
try {
const fileName = file instanceof File ? file.name : '文件'
showToast(`文件上传中: ${fileName}`)
const result = await uploadFile(file)
await store.sendNewMessage(`[文件] ${result.filename}`, {
msg_type: 'file',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('文件发送成功')
} catch (error: any) {
console.error('文件上传失败:', error)
showToast(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'文件上传失败,请重试'
)
}
}
/**
* 处理文件选择上传
*/
async function handleFileSelect(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
for (const file of Array.from(files)) {
try {
showToast(`正在上传: ${file.name}`)
const result = await uploadFile(file)
showToast(`${file.name} 上传成功`)
console.log('[InputBar] 文件上传成功:', result.url)
} catch (error: any) {
console.error('文件上传失败:', error)
showToast(`${file.name} 上传失败`)
}
}
input.value = ''
}
/**
* 处理表情按钮点击
* 弹出/收起简易表情选择器(常用 Emoji 面板)
*/
function handleEmoji(): void {
showEmojiPanel.value = !showEmojiPanel.value
}
/**
* 处理文件按钮点击
* 触发文件选择器(不限类型)— v1.2 直接进系统选择器,不展开面板
*/
function handleFile(): void {
if (fileInputRef.value) {
fileInputRef.value.accept = ''
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
}
/**
* 🆕 v1.9:处理群聊按钮点击
* 当前 store 暂无 group chat action,群聊功能开发中,先 toast 提示占位
* 后续接入真实群聊会话时,把 toast 替换为 store.startGroupChat() 即可
*/
function handleGroupChat(): void {
showToast('群聊功能开发中')
}
/**
* 处理坐席头像点击,并将 6 态动作路由到会话 store。
* InputBar 是 v1.3 唯一坐席入口,不向 ChatPanel emit。
*/
async function handleCallAgentClick(): Promise<void> {
const action = computeCallAction(callAgentState.value)
try {
switch (action) {
case 'callAgent':
await store.shakeAgent()
return
case 'cancelQueue':
await store.cancelQueue()
return
case 'endConversation':
await store.closeCurrentConversation()
return
case 'reopenConversation':
await store.reopenCurrentConversation()
return
case 'none':
return
}
} catch (error: any) {
console.error('[InputBar] 坐席操作失败:', error)
showToast(error?.message || '坐席操作失败,请稍后重试')
}
}
// ============================================================================
// 语音识别交互
// ============================================================================
/**
* 处理语音按钮点击 / Ctrl+D 快捷键(双策略)
*/
async function handleVoiceToggle(event?: Event): Promise<void> {
event?.preventDefault()
event?.stopPropagation()
if (voiceStrategy.value === 'none') return
if (voiceStrategy.value === 'wecom') {
// 企微 JS-SDK 策略(手机端企微)
try {
if (voiceState.isRecording) {
const text = await stopAndTranslate()
if (text) {
if (shouldAddSpaceBefore(inputText.value)) {
inputText.value += ' '
}
inputText.value += text
// 🆕 v1.2:识别成功短暂显示绿色对勾(1.5s 回默认)
markVoiceRecognized()
showToast('语音识别完成')
} else {
showToast('未识别到语音内容')
}
} else {
await startRecording()
showToast('正在录音,点击停止')
}
} catch (error: any) {
const errMsg = voiceState.error || error?.message || '语音识别失败'
showToast(errMsg)
console.error('[InputBar] 企微语音识别错误:', error)
}
} else if (voiceStrategy.value === 'baidu') {
// 百度ASR 策略(PC/Mac 端)
if (isBaiduRecording.value) {
const blob = baiduRecorder.stop()
if (blob.size < 1600) {
showToast('录音过短,请说更多内容')
return
}
isTranscribing.value = true
try {
const result = await transcribeAudio(blob)
if (result.error) {
showToast(result.error)
} else if (result.text) {
if (shouldAddSpaceBefore(inputText.value)) {
inputText.value += ' '
}
inputText.value += result.text
// 🆕 v1.2:识别成功短暂显示绿色对勾(1.5s 回默认)
markVoiceRecognized()
showToast('语音识别完成')
} else {
showToast('未识别到语音内容')
}
} catch (err: any) {
showToast(err?.message || '语音识别失败')
console.error('[InputBar] 百度ASR识别错误:', err)
} finally {
isTranscribing.value = false
}
} else {
try {
await baiduRecorder.start()
showToast('开始语音输入')
} catch (err: any) {
showToast(err?.message || '录音启动失败')
console.error('[InputBar] 百度录音启动失败:', err)
}
}
}
}
/**
* 🆕 v1.2:语音识别完成短暂显示绿色对勾(1.5s 后回默认态)
*/
function markVoiceRecognized(): void {
voiceRecognitionCompleted.value = true
setTimeout(() => {
voiceRecognitionCompleted.value = false
}, 1500)
}
/**
* 全局键盘快捷键处理(Ctrl+D 语音识别)
*/
function handleGlobalKeydown(event: KeyboardEvent): void {
if (event.ctrlKey && (event.key === 'd' || event.key === 'D')) {
event.preventDefault()
if (!showVoiceButton.value) return
handleVoiceToggle()
}
}
// ============================================================================
// 拖拽调节输入栏高度
// ============================================================================
/**
* 拖拽开始:记录初始 Y 坐标
* 向上拖拽增大输入栏高度,向下拖拽减小
*/
function handleInputResizeStart(event: MouseEvent): void {
isInputResizing.value = true
const startY = event.clientY
const inputBar = (event.target as HTMLElement).closest('.input-bar') as HTMLElement
const startHeight = inputBar ? inputBar.offsetHeight : 100
function handleMouseMove(e: MouseEvent): void {
if (!isInputResizing.value) return
const deltaY = startY - e.clientY
let newHeight = startHeight + deltaY
newHeight = Math.max(100, Math.min(400, newHeight))
if (inputBar) {
inputBar.style.height = `${newHeight}px`
inputBar.style.flexShrink = '0'
}
}
function handleMouseUp(): void {
isInputResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
</script>
<style scoped>
/* ==========================================================================
输入栏容器(v1.9 三区融合关键)
- 背景透明 → 让 ChatPanel 的渐变背景透出,三区(消息区/工具栏/输入区)连续
- border-top 保留(仍属容器 chrome,与外部消息区分隔);内部子元素无 border
========================================================================== */
.input-bar {
background-color: transparent;
border-top: 1px solid var(--border-color);
padding: 0 12px 8px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
flex-shrink: 0;
position: relative;
}
/* v1.9 键盘可达性:所有按钮可见 focus 蓝环 */
.glass-btn:focus-visible,
.agent-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
/* ==========================================================================
v1.9 窄屏 fallback(≤480px
- 拱形轨道在窄屏易走样,隐藏 SVG、改胶囊样式
- 按钮尺寸适度缩小(玻璃按钮 36px / 坐席 52px
========================================================================== */
@media (max-width: 480px) {
.gem-toolbar {
height: 56px;
padding: 0 12px;
margin: 0 auto 4px;
}
.gem-toolbar-bg {
display: none;
}
.gem-row {
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
padding: 6px 12px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 -1px 0 rgba(99, 102, 241, 0.08) inset,
0 2px 4px rgba(99, 102, 241, 0.05),
0 8px 16px rgba(99, 102, 241, 0.08);
}
.gem-row .glass-btn {
width: 36px;
height: 36px;
}
.gem-row .agent-btn.gem {
width: 52px;
height: 52px;
margin: 0 4px;
}
}
/* ==========================================================================
v1.9 深色模式预案(prefers-color-scheme: dark
- 当前为预留骨架,色彩需后续设计 token 全局对齐
========================================================================== */
@media (prefers-color-scheme: dark) {
.input-bar {
border-top-color: rgba(255, 255, 255, 0.1);
}
.gem-row {
/* 窄屏 fallback 启用时(≤480px)才生效 */
}
/* 在窄屏(≤480px)下,gem-row 会变成胶囊背景,深色模式加深 */
@media (max-width: 480px) {
.gem-row {
background: rgba(30, 30, 40, 0.55);
border-color: rgba(255, 255, 255, 0.15);
}
}
/* 输入框深色模式 */
.input-bar__field {
background-color: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
}
/* ── 拖拽手柄 ── */
.input-bar__resize-handle {
height: 8px;
cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
margin: 0 -12px;
padding: 2px 0;
}
.input-bar__resize-track {
width: 40px;
height: 3px;
border-radius: 2px;
background-color: var(--border-color);
transition: background-color 0.2s, width 0.2s;
}
.input-bar__resize-handle:hover .input-bar__resize-track {
background-color: var(--accent);
width: 60px;
}
/* ==========================================================================
v1.9 圆润拱形工具栏 — InputBar 内顶部居中独立行
- 位置:resize-handle 之后,emoji-panel / input-bar__row 之前
- 容器 .gem-toolbar 透明,让消息区背景透出(v1.9 三区融合关键)
- .gem-toolbar-bg 拱形轨道 SVGviewBox 0 0 312 84,宽穹顶 x 84..216
- 5 按钮:emoji / 文件 / 坐席(60px 居中) / 语音 / 群聊
- focus-visible 蓝环(键盘可达性)
- @media (max-width: 480px) 窄屏 fallback:隐藏 SVG、改胶囊
- @media (prefers-color-scheme: dark) 深色模式预留
========================================================================== */
.gem-toolbar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
width: fit-content;
height: 84px;
margin: 0 auto 4px;
background: transparent;
}
.gem-toolbar-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.gem-row {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
/* 旧 .glass-toolbar 兼容层:保留 class 以防外部依赖挂样式,但不渲染(v1.9 不再使用) */
.glass-toolbar {
/* v1.9 不再使用此容器类,CSS 留空避免旧规则冲突 */
display: none;
}
/* ==========================================================================
40px 圆形玻璃按钮(emoji / file / voice
========================================================================== */
.glass-btn {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
position: relative;
z-index: 2;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 -1px 0 rgba(0, 0, 0, 0.04) inset,
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.04);
padding: 0;
outline: none;
overflow: visible;
}
.glass-btn:hover {
background: rgba(255, 255, 255, 0.75);
transform: translateY(-1px) scale(1.06);
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 -1px 0 rgba(0, 0, 0, 0.04) inset,
0 2px 4px rgba(0, 0, 0, 0.06),
0 8px 16px rgba(99, 102, 241, 0.15);
}
.glass-btn:active {
transform: translateY(0) scale(0.95);
}
.glass-btn svg {
width: 22px;
height: 22px;
display: block;
pointer-events: none;
}
/* ── 表情按钮激活态(黄橙渐变) ── */
.glass-btn.is-active-emoji {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.9) 0%, rgba(245, 158, 11, 0.9) 100%);
border-color: rgba(255, 255, 255, 0.4);
}
.glass-btn.is-active-emoji::before {
content: "";
position: absolute;
inset: 1px;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 50%);
pointer-events: none;
}
/* ── 语音按钮 3 态(颜色+形态双变) ── */
.glass-btn.is-voice-recording {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95) 0%, rgba(220, 38, 38, 0.95) 100%);
border-color: rgba(255, 255, 255, 0.4);
animation: recordingPulse 1.5s ease-in-out infinite;
}
@keyframes recordingPulse {
0%, 100% {
box-shadow:
0 0 0 0 rgba(239, 68, 68, 0),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
50% {
box-shadow:
0 0 0 6px rgba(239, 68, 68, 0.15),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
}
.glass-btn.is-voice-recognized {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.95) 0%, rgba(5, 150, 105, 0.95) 100%);
border-color: rgba(255, 255, 255, 0.4);
}
.glass-btn.is-voice-default {
/* 默认态:透明玻璃(无额外样式) */
}
.glass-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
animation: none;
}
/* ==========================================================================
44px 坐席头像按钮(图片头像 + 紫粉渐变边框)
v1.3 class: .agent-btn(原 .input-bar__agent 重命名)
v1.9 增强:.agent-btn.gem 修饰符把坐席放大到 60px(较 40px 工具图标大 50%
========================================================================== */
.agent-btn {
width: 44px;
height: 44px;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
position: relative;
z-index: 2;
overflow: visible;
cursor: pointer;
background: linear-gradient(135deg, #6366f1, #a855f7 50%, #ec4899) padding-box;
transition: transform 0.25s ease, opacity 0.2s ease, box-shadow 0.25s ease;
-webkit-tap-highlight-color: transparent;
outline: none;
}
/* v1.9 gem 修饰符:坐席按钮增大到 60px,居中、不探出、无光晕 */
.gem-row .agent-btn.gem {
width: 60px;
height: 60px;
margin: 0 6px;
z-index: 3;
box-shadow: none;
}
.agent-btn::before {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
z-index: -1;
}
.agent-btn:hover {
transform: translateY(-1px) scale(1.05);
}
.agent-btn:active {
transform: scale(0.95);
}
.agent-btn:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.agent-btn__avatar {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center 15%;
}
/* 6 态 modifier(沿用 v1.2 helper 契约) */
.agent-btn.is-online {
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
}
.agent-btn.is-urgent {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.6), 0 4px 12px rgba(239, 68, 68, 0.3);
animation: agentUrgentPulse 1.2s ease-in-out infinite;
}
.agent-btn.is-waiting {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6);
}
.agent-btn.is-end {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.6);
}
.agent-btn.is-offline {
opacity: 0.4;
filter: grayscale(0.7);
}
@keyframes agentUrgentPulse {
0%, 100% { box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.6), 0 4px 12px rgba(239, 68, 68, 0.3); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.4), 0 4px 16px rgba(239, 68, 68, 0.5); }
}
/* 兼容 IntegrationZone computeIntegrationBtnClass 输出的 6 态 classcall-agent-btn--* */
.agent-btn.call-agent-btn--active {
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
}
.agent-btn.call-agent-btn--urgent {
animation: agentUrgentPulse 1.2s ease-in-out infinite;
}
.agent-btn.call-agent-btn--waiting {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6);
}
.agent-btn.call-agent-btn--end,
.agent-btn.call-agent-btn--reopen {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.6);
}
.agent-btn.call-agent-btn--disabled {
opacity: 0.4;
filter: grayscale(0.7);
cursor: not-allowed;
}
/* ==========================================================================
坐席头像右下角状态徽标 — 5 色(is-online / is-urgent / is-waiting / is-offline / is-end
========================================================================== */
.agent-badge {
position: absolute;
right: 0;
bottom: 0;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
z-index: 3;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.agent-badge.is-online { background: #10b981; }
.agent-badge.is-urgent { background: #ef4444; }
.agent-badge.is-waiting { background: #f59e0b; }
.agent-badge.is-offline { background: #9ca3af; }
.agent-badge.is-end { background: #3b82f6; }
.agent-btn.is-urgent .agent-badge {
animation: badgePulse 1.5s ease-in-out infinite;
}
@keyframes badgePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6), 0 1px 2px rgba(0, 0, 0, 0.2); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); }
}
/* ========================================================================
表情面板:从工具栏方向上滑展开(slideUp)
======================================================================== */
.emoji-panel {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(99, 102, 241, 0.15);
border-radius: 14px;
padding: 12px;
margin-bottom: 8px;
box-shadow: 0 -4px 16px rgba(99, 102, 241, 0.1);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
max-height: 0;
}
to {
opacity: 1;
transform: translateY(0);
max-height: 400px;
}
}
.emoji-panel__grid {
display: grid;
grid-template-columns: repeat(8, 36px);
gap: 4px;
justify-content: center;
}
.emoji-panel__item {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.emoji-panel__item:hover {
background: rgba(99, 102, 241, 0.1);
}
.emoji-panel__item:active {
background: rgba(99, 102, 241, 0.15);
transform: scale(1.2);
}
/* 表情面板遮罩(点击关闭) */
.emoji-panel__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
/* ==========================================================================
输入行:块级布局(发送按钮 absolute 右下角)
========================================================================== */
.input-bar__row {
display: block;
position: relative;
}
/* ==========================================================================
文本输入框
========================================================================== */
.input-bar__field {
display: block;
width: 100%;
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 10px 80px 10px 10px;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
box-sizing: border-box;
font-family: inherit;
font-size: 14px;
color: var(--text-primary);
}
.input-bar__field:focus-within {
border-color: var(--accent);
}
.input-bar__field :deep(.van-field__control) {
font-size: 14px;
line-height: 1.5;
}
.input-bar__field :deep(.van-field__body) {
padding: 0;
}
/* ==========================================================================
发送按钮
========================================================================== */
.input-bar__send-btn {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 2;
height: 36px;
border-radius: 8px;
min-width: 56px;
}
/* ==========================================================================
语音状态提示条(百度ASR内联录音时显示)
========================================================================== */
.input-bar__voice-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin: 4px 0;
font-size: 12px;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-radius: 6px;
animation: voice-status-blink 1.2s ease-in-out infinite;
width: fit-content;
}
@keyframes voice-status-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style>