1524 lines
47 KiB
Vue
1524 lines
47 KiB
Vue
|
|
<!-- =============================================================================
|
|||
|
|
// 企微IT智能服务台 — H5用户端输入栏组件(v1.3 视觉重塑)
|
|||
|
|
// =============================================================================
|
|||
|
|
// 说明:底部输入栏,固定在消息列表下方。
|
|||
|
|
// v1.3 视觉重塑:
|
|||
|
|
// - 工具栏从 InputBar 内下方 → 移到 InputBar 顶部(resize-handle 之后)
|
|||
|
|
// - 工具栏 class: .input-bar__toolbar → .glass-toolbar(玻璃拟态胶囊)
|
|||
|
|
// - 按钮 class: .input-bar__btn → .glass-btn(40px 圆形玻璃)
|
|||
|
|
// - 坐席按钮 class: .input-bar__agent → .agent-btn(44px 图片头像 + 紫粉渐变边框)
|
|||
|
|
// - 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. 人工坐席按钮(居中,60px,v1.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 按钮)] [表情面板] [输入行:输入框 + 发送按钮]
|
|||
|
|
* 工具栏:emoji(SVG 表情)/ file(SVG 文件)/ voice(SVG 话筒)/ 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 态的 class(is-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 拱形轨道 SVG(viewBox 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 态 class(call-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>
|