Files
wecom_it_smart_desk/archives/2026-08-05-v1.9-handoff/InputBar.vue
T
2026-08-11 14:15:36 +08:00

1524 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端输入栏组件(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>