dev 分支暂存
This commit is contained in:
@@ -1,80 +1,8 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
|
||||
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||
const { messages, isTyping, addMessage, setTyping } = useChatStore();
|
||||
const { connect, sendMessage, onMessage } = useWebSocket(sessionId);
|
||||
export function ChatContainer() {
|
||||
const { messages, isTyping } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
// 连接WebSocket (使用JWT token)
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) connect(token);
|
||||
|
||||
// 监听回复
|
||||
onMessage('onResponse', (msg) => {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
role: 'assistant',
|
||||
content: msg.text || '',
|
||||
audioUrl: msg.full_audio_url,
|
||||
segments: msg.segments?.map(s => ({
|
||||
index: s.index,
|
||||
text: s.text,
|
||||
audioUrl: s.audio_url,
|
||||
})),
|
||||
timestamp: msg.timestamp,
|
||||
isStreaming: false,
|
||||
});
|
||||
setTyping(false);
|
||||
});
|
||||
|
||||
onMessage('onError', (msg) => {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
role: 'assistant',
|
||||
content: '啊……不好意思,人家刚才走神了。能再说一遍吗?♪',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setTyping(false);
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const handleSend = useCallback((content: string, mode: string) => {
|
||||
// 添加用户消息
|
||||
addMessage({
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setTyping(true);
|
||||
sendMessage(content, mode);
|
||||
}, [sendMessage, addMessage, setTyping]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]">
|
||||
{/* 顶部栏 */}
|
||||
<header className="flex items-center justify-between px-6 py-3 border-b border-pink-100 dark:border-pink-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-pink-600">昔涟</h1>
|
||||
<MoodIndicator />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">🌸 永远在你身边</span>
|
||||
</header>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<MessageList messages={messages} isTyping={isTyping} />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<ChatInput onSend={handleSend} disabled={isTyping} />
|
||||
</div>
|
||||
);
|
||||
return <MessageList messages={messages} isTyping={isTyping} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { ChatMode } from '@/types/chat';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, mode: ChatMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [mode, setMode] = useState<ChatMode>('text');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
|
||||
onSend(trimmed, mode);
|
||||
setContent('');
|
||||
|
||||
// 重置文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [content, mode, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-t border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'text'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="文字模式"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('voice_msg')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'voice_msg'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="语音消息"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="和昔涟说点什么吧..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !content.trim()}
|
||||
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode !== 'text' && (
|
||||
<p className="text-xs text-gray-400 text-center mt-2">
|
||||
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,35 +4,51 @@ interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp }: MessageBubbleProps) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end px-4 py-2">
|
||||
<div className="max-w-[70%] bg-pink-400 text-white rounded-2xl rounded-br-md px-4 py-2 shadow-sm">
|
||||
<p className="text-sm leading-relaxed">{content}</p>
|
||||
<span className="text-xs text-pink-100 mt-1 block">
|
||||
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex px-4 py-2 gap-3">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<div className="max-w-[70%]">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-2 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-1 block ml-1">
|
||||
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{/* 头像 */}
|
||||
{!isUser && (
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
)}
|
||||
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
className={`
|
||||
max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm
|
||||
${
|
||||
isUser
|
||||
? 'bg-pink-400 text-white rounded-br-md'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900'
|
||||
}
|
||||
${isStreaming ? 'animate-pulse' : ''}
|
||||
`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{content}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 用户头像占位 */}
|
||||
{isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
|
||||
<span className="text-white text-sm">开拓者</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { Message } from '@/types/chat';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
|
||||
<div className="text-6xl mb-4">🌸</div>
|
||||
<p className="text-lg font-medium text-pink-300 mb-2">
|
||||
昔涟在这里等你哦 ♪
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
无论是开心的事还是烦恼,都可以和人家说~
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
/>
|
||||
))}
|
||||
{isTyping && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex px-4 py-2 gap-3">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||
<div className="flex gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-300 animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-400 animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-500 animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]">
|
||||
{/* 侧边栏 */}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-20 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}
|
||||
>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-1 text-gray-400 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-pink-600 dark:text-pink-400">
|
||||
昔涟
|
||||
</h1>
|
||||
<MoodIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 hidden sm:block">🌸 永远在你身边</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 transition-colors px-2 py-1"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useEffect } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onClose }: SidebarProps) {
|
||||
const { sessions, currentSessionId, loadSessions, createSession, deleteSession, setCurrentSession } = useSession();
|
||||
const storeSessions = useSessionStore((s) => s.sessions);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const displaySessions = sessions.length > 0 ? sessions : storeSessions;
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await createSession();
|
||||
if (session && onClose) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="h-full bg-white/90 dark:bg-gray-900/90 border-r border-pink-100 dark:border-pink-900 flex flex-col">
|
||||
{/* 侧边栏头部 */}
|
||||
<div className="p-4 border-b border-pink-100 dark:border-pink-900">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-pink-400 hover:bg-pink-500 text-white rounded-xl text-sm font-medium transition-colors"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>新对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{displaySessions.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">
|
||||
还没有对话哦,开始和昔涟聊天吧 ♪
|
||||
</p>
|
||||
) : (
|
||||
displaySessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
setCurrentSession(session.id);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className={`
|
||||
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
|
||||
${
|
||||
currentSessionId === session.id || session.id === useSessionStore.getState().currentSessionId
|
||||
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{session.title || '新的对话'}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{session.message_count || 0} 条消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="p-4 border-t border-pink-100 dark:border-pink-900">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<p className="font-medium text-pink-400">昔涟 AI</p>
|
||||
<p>v0.1.0 MVP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { usePersonaStore } from '@/store/personaStore';
|
||||
import type { CyreneForm } from '@/types/persona';
|
||||
|
||||
interface CyreneAvatarProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FORM_AVATAR: Record<CyreneForm, string> = {
|
||||
mimi: '🌸',
|
||||
default: '🌺',
|
||||
de_moi_ge: '🌌',
|
||||
};
|
||||
|
||||
const SIZE_CLASS = {
|
||||
sm: 'w-8 h-8 text-lg',
|
||||
md: 'w-12 h-12 text-2xl',
|
||||
lg: 'w-20 h-20 text-4xl',
|
||||
};
|
||||
|
||||
export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) {
|
||||
const { currentForm } = usePersonaStore();
|
||||
const emoji = FORM_AVATAR[currentForm] || '🌸';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md ${className}`}
|
||||
title={`昔涟 · ${currentForm === 'mimi' ? '迷迷' : currentForm === 'de_moi_ge' ? '德谬歌' : '小昔涟'}`}
|
||||
>
|
||||
<span role="img" aria-label="昔涟">
|
||||
{emoji}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePersonaStore, getMoodEmoji } from '@/store/personaStore';
|
||||
import type { Mood } from '@/types/persona';
|
||||
|
||||
const MOOD_LABEL: Record<Mood, string> = {
|
||||
happy: '心情愉快',
|
||||
thoughtful: '正在思考',
|
||||
worried: '有点担心你',
|
||||
playful: '想逗你玩',
|
||||
nostalgic: '有些怀旧',
|
||||
};
|
||||
|
||||
export function MoodIndicator() {
|
||||
const { mood } = usePersonaStore();
|
||||
const emoji = getMoodEmoji(mood);
|
||||
const label = MOOD_LABEL[mood] || mood;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<span>{emoji}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user