feat: app 端 ui 设计完成
This commit is contained in:
232
wei-ai-demo/pages/Interaction.tsx
Normal file
232
wei-ai-demo/pages/Interaction.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user