feat(frontend): add React SPA with chat, auth, and admin pages
- Vite 6 + React 19 + TypeScript + Tailwind 4 + Zustand 5 - Login page with JWT auth - Chat interface: MessageList, ChatInput, ChatContainer - WebSocket hook with auto-reconnect and exponential backoff - Sidebar with new chat button + session navigation - Admin dashboard placeholder - 57 modules, 242KB JS, TypeScript zero errors
This commit is contained in:
1 parent
192144563b
commit
4a71965686
35 files changed
+3146
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import MessageList from './MessageList'
|
||||
import ChatInput from './ChatInput'
|
||||
|
||||
export default function ChatContainer() {
|
||||
const { send } = useWebSocket()
|
||||
const { messages, isStreaming, currentSessionId, inputText, setInput, addMessage, setStreaming } = useChatStore()
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim() || !currentSessionId) return
|
||||
|
||||
const userMsg = {
|
||||
id: crypto.randomUUID(), session_id: currentSessionId,
|
||||
role: 'user' as const, content: inputText, created_at: new Date().toISOString(),
|
||||
}
|
||||
addMessage(userMsg)
|
||||
setStreaming(true)
|
||||
|
||||
// Add placeholder for assistant response
|
||||
addMessage({ id: crypto.randomUUID(), session_id: currentSessionId, role: 'assistant', content: '', created_at: new Date().toISOString() })
|
||||
|
||||
send({ type: 'message', content: inputText, session_id: currentSessionId, timestamp: Date.now() })
|
||||
setInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-auto px-4 py-4">
|
||||
<MessageList messages={messages} />
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<ChatInput
|
||||
value={inputText}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSend: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({ value, onChange, onSend, disabled }: ChatInputProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-800 p-3 shrink-0">
|
||||
<div className="max-w-3xl mx-auto flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? '思考中...' : '输入消息...'}
|
||||
className="flex-1 bg-gray-800 rounded-lg px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Message } from '../../types/chat'
|
||||
|
||||
export default function MessageList({ messages }: { messages: Message[] }) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
|
||||
开始新对话吧~
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === 'user'
|
||||
const isAction = message.role === 'action'
|
||||
|
||||
if (isAction) {
|
||||
return <div className="text-center text-gray-500 text-sm italic py-1">{message.content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[70%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||
isUser
|
||||
? 'bg-purple-600 text-white rounded-br-md'
|
||||
: message.role === 'system'
|
||||
? 'bg-red-900/50 text-red-300 mx-auto text-xs'
|
||||
: 'bg-gray-800 text-gray-100 rounded-bl-md'
|
||||
}`}>
|
||||
{message.content || (message.role === 'assistant' ? <span className="animate-pulse">...</span> : null)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const { username, logout } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-950 text-gray-100">
|
||||
<Header username={username} onLogout={logout} />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Header({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||
return (
|
||||
<header className="h-12 bg-gray-900 border-b border-gray-800 flex items-center px-4 justify-between shrink-0">
|
||||
<Link to="/chat" className="font-bold text-purple-400 text-lg">Cyrene</Link>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<Link to="/admin/dashboard" className="hover:text-gray-200">管理</Link>
|
||||
<span>{username}</span>
|
||||
<button onClick={onLogout} className="hover:text-red-400">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export default function Sidebar() {
|
||||
const navigate = useNavigate()
|
||||
const { currentSessionId, setSessionId, clearMessages } = useChatStore()
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const { session_id } = await api.sessions.create()
|
||||
setSessionId(session_id)
|
||||
clearMessages()
|
||||
navigate(`/chat/${session_id}`)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
|
||||
<div className="p-3">
|
||||
<button onClick={handleNewChat}
|
||||
className="w-full py-2 bg-purple-600 hover:bg-purple-500 rounded text-sm font-medium transition-colors">
|
||||
+ 新对话
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-2 text-sm text-gray-400">
|
||||
{currentSessionId ? (
|
||||
<p className="px-2 py-1 truncate text-xs">会话: {currentSessionId.slice(0, 8)}...</p>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs">暂无对话</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Whitespace-only changes.
Reference in new issue
Block a user