feat: AI 助手支持文字/语音双模式
- 头部新增模式切换(文字/语音),文字=对话框,语音=语音交互按钮 - 语音模式:大麦克风按钮 + 聆听波纹动画 + 识别中状态,点击开始/再次点击结束 - 优先使用 Web Speech API 识别(zh-CN),无权限/不支持时自动兜底模拟识别 - 语音提问以文字形式进入同一对话流,气泡带「语音」标记 - 切换模式保留对话历史;聆听中切换/离开自动中止识别 - 新增 microphone/microphone-filled/microphone-off/message-circle 图标
This commit is contained in:
+186
-15
@@ -3,7 +3,9 @@ import Icon from './Icons';
|
|||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
AI 智能助手对话面板(共享)—— 遵循 bd- 浅色科技 UI 规范
|
AI 智能助手对话面板(共享)—— 遵循 bd- 浅色科技 UI 规范
|
||||||
左侧对话区:消息流 + 快捷提问 + 输入发送(模拟园区知识库回复)
|
两种交互模式:
|
||||||
|
· 文字对话:消息流 + 快捷提问 + 输入发送
|
||||||
|
· 语音对话:语音交互按钮(聆听 → 识别 → 以文字进入对话流)
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
/* ---------- 快捷提问 ---------- */
|
/* ---------- 快捷提问 ---------- */
|
||||||
@@ -15,8 +17,19 @@ const QUICK_QUESTIONS = [
|
|||||||
'三个分区有什么区别?',
|
'三个分区有什么区别?',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* 语音识别兜底题库(无麦克风权限 / 环境不支持时模拟识别) */
|
||||||
|
const VOICE_SAMPLES = [
|
||||||
|
'如何申请入驻园区?',
|
||||||
|
'园区有哪些政策扶持?',
|
||||||
|
'办公场地怎么收费?',
|
||||||
|
'AI 赋能服务有哪些?',
|
||||||
|
'三个分区有什么区别?',
|
||||||
|
'园区有哪些入驻企业?',
|
||||||
|
'怎么联系创业导师?',
|
||||||
|
];
|
||||||
|
|
||||||
/* ---------- 园区知识库(模拟回复) ---------- */
|
/* ---------- 园区知识库(模拟回复) ---------- */
|
||||||
const WELCOME = '您好,我是 OPC 园区 AI 智能助手。\n\n我可以为您解答入驻流程、政策扶持、场地服务、AI 赋能、企业名录等问题。您也可以点击下方快捷提问,或直接输入问题。';
|
const WELCOME = '您好,我是 OPC 园区 AI 智能助手。\n\n我可以为您解答入驻流程、政策扶持、场地服务、AI 赋能、企业名录等问题。您可以在「文字」模式输入问题,或切换到「语音」模式直接说话提问。';
|
||||||
|
|
||||||
const RULES = [
|
const RULES = [
|
||||||
{
|
{
|
||||||
@@ -56,8 +69,8 @@ const RULES = [
|
|||||||
reply: '园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办',
|
reply: '园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /你好|您好|hi|hello|嗨|在吗|hello/,
|
match: /你好|您好|hi|hello|嗨|在吗/,
|
||||||
reply: '您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题,很高兴为您服务。',
|
reply: '您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题,也可以切换到「语音」模式直接说话提问。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /谢谢|感谢|好的|明白|ok/,
|
match: /谢谢|感谢|好的|明白|ok/,
|
||||||
@@ -78,6 +91,7 @@ function getReply(input) {
|
|||||||
/* ---------- 消息 ---------- */
|
/* ---------- 消息 ---------- */
|
||||||
let msgId = 0;
|
let msgId = 0;
|
||||||
const nextId = () => ++msgId;
|
const nextId = () => ++msgId;
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
export default function AiChatPanel() {
|
export default function AiChatPanel() {
|
||||||
const [msgs, setMsgs] = useState(() => [
|
const [msgs, setMsgs] = useState(() => [
|
||||||
@@ -85,22 +99,32 @@ export default function AiChatPanel() {
|
|||||||
]);
|
]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [typing, setTyping] = useState(false);
|
const [typing, setTyping] = useState(false);
|
||||||
|
const [mode, setMode] = useState('text'); // text | voice
|
||||||
|
const [voiceState, setVoiceState] = useState('idle'); // idle | listening | processing
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
const recRef = useRef(null);
|
||||||
|
|
||||||
// 新消息自动滚动到底部
|
// 新消息 / 状态变化自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [msgs, typing]);
|
}, [msgs, typing, mode, voiceState]);
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
// 卸载清理:定时器 + 语音识别
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
try { recRef.current && recRef.current.abort && recRef.current.abort(); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const send = useCallback((raw) => {
|
/** 生成用户消息并触发 AI 回复 */
|
||||||
|
const send = useCallback((raw, viaVoice = false) => {
|
||||||
const text = String(raw || '').trim();
|
const text = String(raw || '').trim();
|
||||||
if (!text || typing) return;
|
if (!text || typing) return;
|
||||||
setInput('');
|
setInput('');
|
||||||
setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text }]);
|
setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]);
|
||||||
setTyping(true);
|
setTyping(true);
|
||||||
const reply = getReply(text);
|
const reply = getReply(text);
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
@@ -115,6 +139,105 @@ export default function AiChatPanel() {
|
|||||||
setMsgs([{ id: nextId(), role: 'ai', text: WELCOME }]);
|
setMsgs([{ id: nextId(), role: 'ai', text: WELCOME }]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/* ================= 语音对话 ================= */
|
||||||
|
|
||||||
|
// 供安全兜底定时器读取当前状态
|
||||||
|
const voiceStateRef = useRef(voiceState);
|
||||||
|
useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]);
|
||||||
|
|
||||||
|
/** 结束聆听:把识别出的文字送入对话流 */
|
||||||
|
const finishVoice = useCallback((text) => {
|
||||||
|
setVoiceState('processing');
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setVoiceState('idle');
|
||||||
|
send(text, true);
|
||||||
|
}, 450);
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
/** 兜底:环境不支持 / 无麦克风时模拟识别 */
|
||||||
|
const fallbackVoice = useCallback(() => {
|
||||||
|
if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; }
|
||||||
|
finishVoice(pick(VOICE_SAMPLES));
|
||||||
|
}, [finishVoice]);
|
||||||
|
|
||||||
|
/** 点击语音按钮:idle → 开始聆听;listening → 结束 */
|
||||||
|
const toggleVoice = useCallback(() => {
|
||||||
|
if (voiceState === 'processing') return;
|
||||||
|
if (voiceState === 'listening') {
|
||||||
|
// 用户主动结束:尝试取识别结果,无结果则兜底
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
if (recRef.current) {
|
||||||
|
try { recRef.current.stop(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return; // 由 onend/onerror 或兜底定时器完成收尾
|
||||||
|
}
|
||||||
|
// 开始聆听
|
||||||
|
setVoiceState('listening');
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (SR) {
|
||||||
|
try {
|
||||||
|
const rec = new SR();
|
||||||
|
rec.lang = 'zh-CN';
|
||||||
|
rec.interimResults = false;
|
||||||
|
rec.maxAlternatives = 1;
|
||||||
|
let done = false;
|
||||||
|
rec.onresult = (e) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
const t = Array.from(e.results).map((r) => r[0].transcript).join(' ');
|
||||||
|
finishVoice(t || pick(VOICE_SAMPLES));
|
||||||
|
};
|
||||||
|
rec.onerror = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
fallbackVoice();
|
||||||
|
};
|
||||||
|
rec.onend = () => {
|
||||||
|
recRef.current = null;
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
fallbackVoice();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recRef.current = rec;
|
||||||
|
rec.start();
|
||||||
|
// 安全兜底:4 秒内无结果则模拟(识别静默失败的环境)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
if (!done && voiceStateRef.current === 'listening') {
|
||||||
|
done = true;
|
||||||
|
try { rec.abort(); } catch { /* ignore */ }
|
||||||
|
fallbackVoice();
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
} catch { /* 继续走兜底 */ }
|
||||||
|
}
|
||||||
|
// 无 SpeechRecognition:模拟聆听 → 识别
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
fallbackVoice();
|
||||||
|
}, 1300);
|
||||||
|
}, [voiceState, finishVoice, fallbackVoice]);
|
||||||
|
|
||||||
|
// 切换模式时中止聆听
|
||||||
|
const switchMode = useCallback((m) => {
|
||||||
|
setMode(m);
|
||||||
|
if (voiceState === 'listening') {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; }
|
||||||
|
setVoiceState('idle');
|
||||||
|
}
|
||||||
|
}, [voiceState]);
|
||||||
|
|
||||||
|
const voiceHint = {
|
||||||
|
idle: '点击麦克风,说出你的问题',
|
||||||
|
listening: '正在聆听… 请说出你的问题',
|
||||||
|
processing: '正在识别语音…',
|
||||||
|
}[voiceState];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bd-chat">
|
<section className="bd-chat">
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
@@ -122,7 +245,23 @@ export default function AiChatPanel() {
|
|||||||
<span className="bd-chat-logo"><Icon name="robot" size={17} /></span>
|
<span className="bd-chat-logo"><Icon name="robot" size={17} /></span>
|
||||||
<div className="bd-chat-titles">
|
<div className="bd-chat-titles">
|
||||||
<b>AI 智能助手</b>
|
<b>AI 智能助手</b>
|
||||||
<span>OPC 园区知识库 · 随时为您解答</span>
|
<span>OPC 园区知识库 · 文字 / 语音双模式</span>
|
||||||
|
</div>
|
||||||
|
<div className="bd-chat-modes">
|
||||||
|
<button
|
||||||
|
className={mode === 'text' ? 'active' : ''}
|
||||||
|
onClick={() => switchMode('text')}
|
||||||
|
title="文字对话"
|
||||||
|
>
|
||||||
|
<Icon name="message-circle" size={13} />文字
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={mode === 'voice' ? 'active' : ''}
|
||||||
|
onClick={() => switchMode('voice')}
|
||||||
|
title="语音对话"
|
||||||
|
>
|
||||||
|
<Icon name="microphone" size={13} />语音
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="bd-chat-model"><i />DeepSeek-V3</span>
|
<span className="bd-chat-model"><i />DeepSeek-V3</span>
|
||||||
<button className="bd-chat-clear" title="清空对话" onClick={clear}>
|
<button className="bd-chat-clear" title="清空对话" onClick={clear}>
|
||||||
@@ -130,14 +269,19 @@ export default function AiChatPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 消息区 */}
|
{/* 消息区(文字 / 语音共用) */}
|
||||||
<div className="bd-chat-msgs" ref={scrollRef}>
|
<div className="bd-chat-msgs" ref={scrollRef}>
|
||||||
{msgs.map((m) => (
|
{msgs.map((m) => (
|
||||||
<div className={`bd-msg ${m.role}`} key={m.id}>
|
<div className={`bd-msg ${m.role}`} key={m.id}>
|
||||||
{m.role === 'ai' && (
|
{m.role === 'ai' && (
|
||||||
<span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>
|
<span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>
|
||||||
)}
|
)}
|
||||||
<div className="bd-msg-bubble">{m.text}</div>
|
<div className="bd-msg-bubble">
|
||||||
|
{m.voice && (
|
||||||
|
<span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>
|
||||||
|
)}
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
{typing && (
|
||||||
@@ -148,7 +292,9 @@ export default function AiChatPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷提问 */}
|
{/* 文字模式:快捷提问 + 输入区 */}
|
||||||
|
{mode === 'text' && (
|
||||||
|
<>
|
||||||
<div className="bd-chat-quick">
|
<div className="bd-chat-quick">
|
||||||
{QUICK_QUESTIONS.map((q) => (
|
{QUICK_QUESTIONS.map((q) => (
|
||||||
<button key={q} onClick={() => send(q)}>
|
<button key={q} onClick={() => send(q)}>
|
||||||
@@ -157,8 +303,6 @@ export default function AiChatPanel() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 输入区 */}
|
|
||||||
<div className="bd-chat-input">
|
<div className="bd-chat-input">
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
@@ -180,6 +324,33 @@ export default function AiChatPanel() {
|
|||||||
<Icon name="send" size={16} />
|
<Icon name="send" size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 语音模式:语音交互按钮 */}
|
||||||
|
{mode === 'voice' && (
|
||||||
|
<div className="bd-chat-voice">
|
||||||
|
<div className={`bd-chat-voice-hint ${voiceState}`}>
|
||||||
|
{voiceState === 'listening' && <span className="bd-voice-wave"><i /><i /><i /><i /><i /></span>}
|
||||||
|
{voiceHint}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`bd-chat-mic ${voiceState}`}
|
||||||
|
onClick={toggleVoice}
|
||||||
|
title={voiceState === 'listening' ? '结束并发送' : '开始语音提问'}
|
||||||
|
>
|
||||||
|
{voiceState === 'listening' && <span className="bd-mic-ring r1" />}
|
||||||
|
{voiceState === 'listening' && <span className="bd-mic-ring r2" />}
|
||||||
|
<Icon
|
||||||
|
name={voiceState === 'listening' ? 'microphone-filled' : 'microphone'}
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="bd-chat-voice-tip">
|
||||||
|
{voiceState === 'listening' ? '再次点击结束并发送' : '点击开始说话 · 再次点击结束'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
||||||
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
||||||
Send, Trash, Eraser, Headset, CircleDot,
|
Send, Trash, Eraser, Headset, CircleDot,
|
||||||
|
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
|
||||||
} from '@appica/icons-react';
|
} from '@appica/icons-react';
|
||||||
|
|
||||||
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */
|
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */
|
||||||
@@ -79,6 +80,10 @@ const NAME_MAP = {
|
|||||||
eraser: Eraser,
|
eraser: Eraser,
|
||||||
headset: Headset,
|
headset: Headset,
|
||||||
'circle-dot': CircleDot,
|
'circle-dot': CircleDot,
|
||||||
|
microphone: Microphone,
|
||||||
|
'microphone-filled': MicrophoneFilled,
|
||||||
|
'microphone-off': MicrophoneOff,
|
||||||
|
'message-circle': MessageCircle,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,4 +117,5 @@ export {
|
|||||||
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
||||||
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
||||||
Send, Trash, Eraser, Headset, CircleDot,
|
Send, Trash, Eraser, Headset, CircleDot,
|
||||||
|
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1986,6 +1986,152 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
/* ---------- 模式切换(文字 / 语音) ---------- */
|
||||||
|
.bd-chat-modes {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(120,160,220,0.12);
|
||||||
|
border: 1px solid rgba(120,160,220,0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bd-chat-modes button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748d;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bd-chat-modes button:hover {
|
||||||
|
color: var(--bd-blue);
|
||||||
|
}
|
||||||
|
.bd-chat-modes button.active {
|
||||||
|
color: var(--bd-blue);
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
box-shadow: 0 2px 8px rgba(47,107,255,0.18);
|
||||||
|
}
|
||||||
|
/* ---------- 语音对话区 ---------- */
|
||||||
|
.bd-chat-voice {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 18px;
|
||||||
|
}
|
||||||
|
.bd-chat-voice-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748d;
|
||||||
|
min-height: 18px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.bd-chat-voice-hint.listening {
|
||||||
|
color: var(--bd-blue);
|
||||||
|
}
|
||||||
|
.bd-chat-voice-hint.processing {
|
||||||
|
color: var(--bd-orange);
|
||||||
|
}
|
||||||
|
/* 聆听波形 */
|
||||||
|
.bd-voice-wave {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.bd-voice-wave i {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bd-blue);
|
||||||
|
animation: bdWave 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.bd-voice-wave i:nth-child(1) { height: 6px; animation-delay: 0s; }
|
||||||
|
.bd-voice-wave i:nth-child(2) { height: 12px; animation-delay: 0.12s; }
|
||||||
|
.bd-voice-wave i:nth-child(3) { height: 8px; animation-delay: 0.24s; }
|
||||||
|
.bd-voice-wave i:nth-child(4) { height: 13px; animation-delay: 0.36s; }
|
||||||
|
.bd-voice-wave i:nth-child(5) { height: 7px; animation-delay: 0.48s; }
|
||||||
|
@keyframes bdWave {
|
||||||
|
0%, 100% { transform: scaleY(0.5); opacity: 0.5; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
/* 语音交互按钮 */
|
||||||
|
.bd-chat-mic {
|
||||||
|
position: relative;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
|
||||||
|
box-shadow: 0 10px 28px rgba(47,107,255,0.32);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.bd-chat-mic:hover {
|
||||||
|
transform: translateY(-2px) scale(1.03);
|
||||||
|
box-shadow: 0 14px 34px rgba(47,107,255,0.4);
|
||||||
|
}
|
||||||
|
.bd-chat-mic.listening {
|
||||||
|
background: linear-gradient(140deg, var(--bd-blue-deep), var(--bd-blue));
|
||||||
|
box-shadow: 0 0 0 0 rgba(47,107,255,0.35);
|
||||||
|
}
|
||||||
|
.bd-chat-mic.processing {
|
||||||
|
background: linear-gradient(140deg, var(--bd-orange), #ffb648);
|
||||||
|
box-shadow: 0 10px 28px rgba(247,144,9,0.3);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
/* 聆听扩散波纹 */
|
||||||
|
.bd-mic-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(47,107,255,0.4);
|
||||||
|
animation: bdMicRing 1.5s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.bd-mic-ring.r2 {
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
@keyframes bdMicRing {
|
||||||
|
0% { transform: scale(1); opacity: 0.8; }
|
||||||
|
100% { transform: scale(1.7); opacity: 0; }
|
||||||
|
}
|
||||||
|
.bd-chat-voice-tip {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #9fb0c6;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
/* 语音提问消息标记 */
|
||||||
|
.bd-msg-voice-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
background: rgba(255,255,255,0.22);
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
/* 移动端/窄屏:消息宽度放开 */
|
/* 移动端/窄屏:消息宽度放开 */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.bd-msg { max-width: 94%; }
|
.bd-msg { max-width: 94%; }
|
||||||
|
|||||||
Reference in New Issue
Block a user