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:
Aska committed 2026-08-12 11:47:22 +08:00
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.