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

+2
View File
@@ -50,3 +50,5 @@ desktop.ini
# 临时文件
tmp/
temp/
node_modules/
dist/
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cyrene — 昔涟</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2536
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "cyrene-web",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import AppLayout from './components/layout/AppLayout'
import ChatPage from './pages/chat/ChatPage'
import DashboardPage from './pages/admin/DashboardPage'
import LoginPage from './pages/LoginPage'
export default function App() {
const isLoggedIn = useAuthStore((s) => s.isLoggedIn)
if (!isLoggedIn) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
return (
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/chat/:sessionId" element={<ChatPage />} />
<Route path="/admin/dashboard" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/chat" replace />} />
</Routes>
</AppLayout>
)
}
View File
Whitespace-only changes.
+44
View File
@@ -0,0 +1,44 @@
// API client — simple fetch wrapper with JWT support
const BASE = '/api/v1'
let token: string | null = localStorage.getItem('cyrene_token')
export function setToken(t: string | null) {
token = t
if (t) localStorage.setItem('cyrene_token', t)
else localStorage.removeItem('cyrene_token')
}
export function getToken() { return token }
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE}${path}`, { ...options, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || err.message || `HTTP ${res.status}`)
}
return res.json()
}
export const api = {
login: (u: string, p: string) =>
request<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }),
health: () => request<{ status: string }>('/health'),
sessions: {
list: () => request<{ sessions: { id: string; title: string }[] }>('/sessions'),
create: () => request<{ session_id: string }>('/sessions', { method: 'POST' }),
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
},
admin: {
dashboard: () => request<{ dashboard: Record<string, unknown> }>('/admin/dashboard'),
},
}
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.
View File
Whitespace-only changes.
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useCallback } from 'react'
import { useChatStore } from '../store/chatStore'
import { getToken } from '../api/client'
import type { WSMessage } from '../types/chat'
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectRef = useRef(0)
const { addMessage, appendToLast, setStreaming, setIotDevices, currentSessionId } = useChatStore()
const connect = useCallback(() => {
const token = getToken()
if (!token) return
const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${encodeURIComponent(token)}`)
wsRef.current = ws
ws.onopen = () => {
console.log('[ws] connected')
reconnectRef.current = 0
}
ws.onmessage = (e) => {
try {
const msg: WSMessage = JSON.parse(e.data)
switch (msg.type) {
case 'stream_chunk':
appendToLast(msg.content || '')
break
case 'stream_end':
setStreaming(false)
break
case 'response':
if (msg.content) {
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId || '',
role: msg.content.startsWith('[') ? 'action' : 'assistant',
content: msg.content, created_at: new Date().toISOString(),
})
}
break
case 'device_update':
if (msg.devices) setIotDevices(msg.devices)
break
case 'error':
addMessage({ id: crypto.randomUUID(), session_id: '', role: 'system',
content: `Error: ${msg.error}`, created_at: new Date().toISOString() })
break
}
} catch { /* ignore parse errors */ }
}
ws.onclose = () => {
console.log('[ws] disconnected')
const delay = Math.min(1000 * 2 ** reconnectRef.current, 30000)
reconnectRef.current++
setTimeout(connect, delay)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSessionId])
useEffect(() => { connect(); return () => wsRef.current?.close() }, [connect])
const send = useCallback((data: object) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data))
}
}, [])
return { send }
}
+9
View File
@@ -0,0 +1,9 @@
@import "tailwindcss";
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
+38
View File
@@ -0,0 +1,38 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
export default function LoginPage() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('admin')
const [error, setError] = useState('')
const navigate = useNavigate()
const login = useAuthStore((s) => s.login)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(username, password)
navigate('/chat')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-xl w-80 space-y-4 border border-gray-800">
<h1 className="text-2xl font-bold text-center text-purple-400">Cyrene</h1>
{error && <p className="text-red-400 text-sm text-center">{error}</p>}
<input type="text" value={username} onChange={e => setUsername(e.target.value)}
placeholder="用户名" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="密码" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-500 py-2 rounded text-sm font-medium transition-colors">
登录
</button>
</form>
</div>
)
}
Whitespace-only changes.
@@ -0,0 +1,17 @@
export default function DashboardPage() {
return (
<div className="p-6 max-w-3xl mx-auto">
<h1 className="text-xl font-bold mb-4">管理面板</h1>
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-gray-400 text-sm">服务状态</p>
<p className="text-green-400 text-lg font-bold mt-1">运行中</p>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-gray-400 text-sm">版本</p>
<p className="text-purple-400 text-lg font-bold mt-1">2.0.0-dev</p>
</div>
</div>
</div>
)
}
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useChatStore } from '../../store/chatStore'
import ChatContainer from '../../components/chat/ChatContainer'
import { api } from '../../api/client'
export default function ChatPage() {
const { sessionId } = useParams()
const { setSessionId } = useChatStore()
useEffect(() => {
if (sessionId) setSessionId(sessionId)
}, [sessionId, setSessionId])
return <ChatContainer />
}
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
import { api, setToken, getToken } from '../api/client'
interface AuthState {
isLoggedIn: boolean
username: string
role: string
login: (username: string, password: string) => Promise<void>
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
isLoggedIn: !!getToken(),
username: '',
role: 'user',
login: async (username: string, password: string) => {
const { token } = await api.login(username, password)
setToken(token)
set({ isLoggedIn: true, username, role: 'admin' })
},
logout: () => {
setToken(null)
set({ isLoggedIn: false, username: '', role: 'user' })
},
}))
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand'
import type { Message, IotDevice } from '../types/chat'
interface ChatState {
messages: Message[]
isStreaming: boolean
currentSessionId: string | null
iotDevices: IotDevice[]
inputText: string
setInput: (text: string) => void
setSessionId: (id: string) => void
addMessage: (msg: Message) => void
appendToLast: (content: string) => void
setStreaming: (v: boolean) => void
setIotDevices: (d: IotDevice[]) => void
clearMessages: () => void
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
isStreaming: false,
currentSessionId: null,
iotDevices: [],
inputText: '',
setInput: (text) => set({ inputText: text }),
setSessionId: (id) => set({ currentSessionId: id }),
setStreaming: (v) => set({ isStreaming: v }),
setIotDevices: (d) => set({ iotDevices: d }),
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
appendToLast: (content) => {
const msgs = [...get().messages]
if (msgs.length > 0) {
msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content: msgs[msgs.length - 1].content + content }
}
set({ messages: msgs })
},
clearMessages: () => set({ messages: [] }),
}))
View File
Whitespace-only changes.
+35
View File
@@ -0,0 +1,35 @@
// Chat-related type definitions
export interface Message {
id: string
session_id: string
role: 'user' | 'assistant' | 'action' | 'system'
content: string
created_at: string
}
export interface Session {
id: string
title: string
state: 'active' | 'idle'
updated_at: string
}
export interface IotDevice {
id: string
name: string
type: string
state: Record<string, unknown>
online: boolean
}
export interface WSMessage {
type: string
content?: string
session_id?: string
timestamp?: number
error?: string
devices?: IotDevice[]
title?: string
body?: string
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8080',
'/ws': { target: 'ws://localhost:8080', ws: true },
},
},
})