Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)

This commit is contained in:
hhhge committed 2026-10-03 20:47:44 +08:00
commit 7f5ea6bee8
55 files changed
+6999

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { BoardInfo } from '../types'
import ThemeToggle from '../components/ThemeToggle'
import './Lobby.scss'
export function getNickname(): string {
return localStorage.getItem('ww_name') || ''
}
export function setNickname(name: string) {
localStorage.setItem('ww_name', name)
}
export default function Lobby() {
const nav = useNavigate()
const [name, setName] = useState(getNickname())
const [boards, setBoards] = useState<BoardInfo[]>([])
const [board, setBoard] = useState('classic12')
const [joinCode, setJoinCode] = useState('')
const [health, setHealth] = useState<{ llm_configured: boolean; model: string | null } | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/health').then((r) => r.json()).then(setHealth).catch(() => {})
fetch('/api/boards').then((r) => r.json()).then((b: BoardInfo[]) => {
setBoards(b)
if (b.length && !b.some((x) => x.key === board)) setBoard(b[0].key)
}).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const ensureName = (): boolean => {
if (!name.trim()) {
setError('请先输入你的昵称')
return false
}
setNickname(name.trim())
return true
}
const createRoom = async () => {
if (!ensureName()) return
setBusy(true)
setError('')
try {
const res = await fetch('/api/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ board, host_name: name.trim() }),
})
const data = await res.json()
nav(`/room/${data.code}`)
} catch {
setError('创建房间失败,请确认后端已启动')
} finally {
setBusy(false)
}
}
const joinRoom = () => {
if (!ensureName()) return
const code = joinCode.trim().toUpperCase()
if (!code) {
setError('请输入房间号')
return
}
nav(`/room/${code}`)
}
const selectedBoard = boards.find((b) => b.key === board)
return (
<div className="lobby">
<div className="lobby-card">
<div className="card-top">
<ThemeToggle />
</div>
<div className="logo">
<span className="moon">🌕</span>
<h1>狼人杀</h1>
<p className="subtitle">WEREWOLVES · 人机混战 · 多人联机</p>
</div>
<div className="field">
<label>你的昵称</label>
<input
value={name}
maxLength={12}
placeholder="给自己起个名字"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createRoom()}
/>
</div>
<div className="field">
<label>选择板子</label>
<select value={board} onChange={(e) => setBoard(e.target.value)}>
{boards.map((b) => (
<option key={b.key} value={b.key}>
{b.name}
</option>
))}
</select>
{selectedBoard && <p className="hint">共 {selectedBoard.seat_count} 个座位</p>}
</div>
<button className="primary big" disabled={busy} onClick={createRoom}>
{busy ? '创建中…' : '创建房间'}
</button>
<div className="divider">或者</div>
<div className="join-row">
<input
className="code-input"
value={joinCode}
maxLength={4}
placeholder="房间号"
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && joinRoom()}
/>
<button onClick={joinRoom}>加入房间</button>
</div>
{error && <p className="error">{error}</p>}
{health && (
<p className="ai-status">
AI 玩家:
{health.llm_configured ? (
<span className="ok">大模型已接入({health.model})</span>
) : (
<span className="fallback">未配置密钥,使用内置启发式 AI</span>
)}
</p>
)}
</div>
</div>
)
}