232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { Send, Mic, MicOff, ChevronLeft, MoreVertical, Headphones, PhoneOff, Volume2, VolumeX } from 'lucide-react';
|
||
import { Message, Character } from '../types';
|
||
|
||
interface InteractionProps {
|
||
character: Character | null;
|
||
onBack: () => void;
|
||
}
|
||
|
||
const Interaction: React.FC<InteractionProps> = ({ character, onBack }) => {
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [isVoiceMode, setIsVoiceMode] = useState(false);
|
||
const [isMicMuted, setIsMicMuted] = useState(false);
|
||
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (character) {
|
||
setMessages([
|
||
{
|
||
id: 'init',
|
||
text: character.description ? `${character.description}\n\n指挥官,${character.name} 已就位。` : '连接建立。',
|
||
sender: 'ai',
|
||
type: 'text',
|
||
timestamp: Date.now(),
|
||
}
|
||
]);
|
||
}
|
||
}, [character]);
|
||
|
||
useEffect(() => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||
}
|
||
}, [messages]);
|
||
|
||
const handleSend = () => {
|
||
if (!inputValue.trim()) return;
|
||
const newMsg: Message = {
|
||
id: Date.now().toString(),
|
||
text: inputValue,
|
||
sender: 'user',
|
||
type: 'text',
|
||
timestamp: Date.now(),
|
||
};
|
||
setMessages(prev => [...prev, newMsg]);
|
||
setInputValue('');
|
||
setTimeout(() => {
|
||
const aiMsg: Message = {
|
||
id: (Date.now() + 1).toString(),
|
||
text: '收到反馈。硬件同步率正在上升...',
|
||
sender: 'ai',
|
||
type: 'text',
|
||
timestamp: Date.now(),
|
||
};
|
||
setMessages(prev => [...prev, aiMsg]);
|
||
}, 1500);
|
||
};
|
||
|
||
if (isVoiceMode && character) {
|
||
return (
|
||
<div className="fixed inset-0 z-[70] bg-[#2e1065] flex flex-col items-center justify-between py-safe overflow-hidden">
|
||
<div className="absolute inset-0 z-0">
|
||
<img src={character.avatar} className="w-full h-full object-cover opacity-30 blur-2xl scale-125" />
|
||
<div className="absolute inset-0 bg-gradient-to-b from-[#2e1065] via-[#4c1d95]/80 to-[#2e1065]"></div>
|
||
</div>
|
||
|
||
<div className="relative z-10 w-full px-6 pt-6 flex justify-between items-start">
|
||
<button onClick={() => setIsVoiceMode(false)} className="p-2 rounded-full bg-white/10 backdrop-blur-sm border border-white/10 text-white/70 active:bg-white/20 transition-colors">
|
||
<ChevronLeft size={24} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center w-full">
|
||
<div className="text-center mb-12">
|
||
<h2 className="text-2xl font-bold text-white tracking-wide drop-shadow-lg mb-2">{character.name}</h2>
|
||
<p className="text-white/60 text-xs font-medium tracking-[0.2em] uppercase">
|
||
{isMicMuted ? 'Mic Muted' : 'Listening...'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="relative w-48 h-48 flex items-center justify-center">
|
||
{!isMicMuted && (
|
||
<>
|
||
<div className="absolute inset-0 rounded-full border border-white/20 animate-[ping_3s_ease-in-out_infinite] opacity-50"></div>
|
||
<div className="absolute inset-0 rounded-full border border-white/10 animate-[ping_3s_ease-in-out_infinite_1s] opacity-30"></div>
|
||
</>
|
||
)}
|
||
<div className="w-40 h-40 rounded-full overflow-hidden border-2 border-white/30 relative shadow-[0_0_50px_rgba(192,132,252,0.5)] z-10">
|
||
<img src={character.avatar} className="w-full h-full object-cover" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-12 flex items-center gap-1 h-8">
|
||
{[...Array(5)].map((_, i) => (
|
||
<div key={i} className={`w-1 bg-white rounded-full transition-all duration-300 ${isMicMuted ? 'h-1 opacity-20' : 'animate-pulse'}`} style={{ height: isMicMuted ? 4 : Math.random() * 24 + 8, animationDelay: `${i * 0.1}s` }}></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative z-10 w-full px-12 pb-12 flex items-center justify-between">
|
||
<button
|
||
onClick={() => setIsMicMuted(!isMicMuted)}
|
||
className={`p-4 rounded-full border transition-all duration-300 ${
|
||
isMicMuted
|
||
? 'bg-white text-[#2e1065] border-white shadow-lg'
|
||
: 'bg-white/10 backdrop-blur-md border-white/10 text-white hover:bg-white/20'
|
||
}`}
|
||
>
|
||
{isMicMuted ? <MicOff size={24} /> : <Mic size={24} />}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setIsVoiceMode(false)}
|
||
className="w-20 h-20 rounded-full bg-red-500 text-white flex items-center justify-center shadow-lg active:scale-95 transition-transform"
|
||
>
|
||
<PhoneOff size={32} fill="white" />
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setIsSpeakerOn(!isSpeakerOn)}
|
||
className={`p-4 rounded-full border transition-all duration-300 ${
|
||
isSpeakerOn
|
||
? 'bg-white/10 backdrop-blur-md border-white/30 text-white'
|
||
: 'bg-white/5 backdrop-blur-md border-white/10 text-white/40'
|
||
}`}
|
||
>
|
||
{isSpeakerOn ? <Volume2 size={24} /> : <VolumeX size={24} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!character) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[60] bg-[#2e1065] flex flex-col overflow-hidden">
|
||
{/* Background */}
|
||
<div className="absolute inset-0 z-0 pointer-events-none">
|
||
<img
|
||
src={character.avatar}
|
||
alt="AI Character"
|
||
className="w-full h-full object-cover opacity-20 blur-xl scale-110"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-[#2e1065] via-[#2e1065]/90 to-[#2e1065]/50"></div>
|
||
</div>
|
||
|
||
{/* Header */}
|
||
<div className="relative z-20 pt-safe px-4 py-3 flex items-center justify-between border-b border-white/10 bg-[#2e1065]/60 backdrop-blur-md">
|
||
<button onClick={onBack} className="p-2 -ml-2 text-white/80 hover:text-white active:scale-95 transition-transform">
|
||
<ChevronLeft size={24} strokeWidth={1.5} />
|
||
</button>
|
||
|
||
<div className="flex flex-col items-center">
|
||
<h3 className="text-base font-bold text-white tracking-wide drop-shadow-md">{character.name}</h3>
|
||
<span className="text-[10px] text-white/60 font-medium tracking-wider uppercase">{character.tagline}</span>
|
||
</div>
|
||
|
||
<button className="p-2 -mr-2 text-white/70 hover:text-white active:scale-95 transition-transform">
|
||
<MoreVertical size={20} strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Chat Area */}
|
||
<div className="flex-1 z-10 flex flex-col justify-end pb-2 px-3 overflow-hidden relative">
|
||
<div className="absolute top-0 left-0 w-full h-8 bg-gradient-to-b from-[#2e1065]/50 to-transparent z-10 pointer-events-none"></div>
|
||
|
||
<div
|
||
ref={scrollRef}
|
||
className="flex-1 overflow-y-auto no-scrollbar space-y-6 pt-4 pb-2"
|
||
>
|
||
{messages.map((msg) => (
|
||
<div key={msg.id} className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'} group items-end`}>
|
||
{msg.sender === 'ai' && (
|
||
<div className="w-8 h-8 rounded-full overflow-hidden mr-3 border border-white/20 shrink-0">
|
||
<img src={character.avatar} className="w-full h-full object-cover" />
|
||
</div>
|
||
)}
|
||
<div className={`max-w-[80%] p-3 relative ${
|
||
msg.sender === 'user'
|
||
? 'bg-gradient-to-br from-[#C084FC] to-[#F472B6] text-white rounded-2xl rounded-tr-sm shadow-md'
|
||
: 'bg-white/10 backdrop-blur-md border border-white/10 text-white rounded-2xl rounded-tl-sm shadow-sm'
|
||
}`}>
|
||
{msg.type === 'text' && <p className="text-sm leading-relaxed tracking-wide font-normal">{msg.text}</p>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Input Area */}
|
||
<div className="relative flex items-center gap-2 pt-1 pb-1">
|
||
<button
|
||
onClick={() => setIsVoiceMode(true)}
|
||
className="w-10 h-10 flex items-center justify-center rounded-full transition-all duration-300 border border-white/10 bg-white/10 backdrop-blur-md text-white/70 hover:text-white hover:bg-white/20"
|
||
>
|
||
<Headphones size={20} strokeWidth={1.5} />
|
||
</button>
|
||
|
||
<div className="flex-1 h-11 bg-white/10 backdrop-blur-md rounded-full flex items-center px-4 border border-white/10 focus-within:border-white/30 focus-within:bg-white/20 transition-all shadow-sm">
|
||
<input
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
placeholder="输入指令..."
|
||
className="bg-transparent w-full text-sm text-white placeholder-white/40 focus:outline-none"
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||
/>
|
||
<button className="text-white/50 hover:text-white ml-1 transition-colors">
|
||
<Mic size={18} strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={!inputValue.trim()}
|
||
className={`w-10 h-10 flex items-center justify-center rounded-full transition-all duration-300 ${
|
||
inputValue.trim()
|
||
? 'bg-white text-[#2e1065] scale-100 opacity-100 shadow-lg'
|
||
: 'bg-white/5 text-white/20 scale-95 opacity-50'
|
||
}`}
|
||
>
|
||
<Send size={18} className="-ml-0.5 mt-0.5" strokeWidth={2.5} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Interaction; |