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.
@@ -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>
)
}