Files
meeting-room/frontend/src/App.js

271 lines
10 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useParams } from 'react-router-dom';
import axios from 'axios';
const API_BASE = 'http://localhost:8000/api/v1';
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// ============ 登录页面 ============
function LoginPage() {
const [username, setUsername] = useState('test');
const [password, setPassword] = useState('test123');
const navigate = useNavigate();
const handleLogin = async (e) => {
e.preventDefault();
try {
const res = await axios.post(`${API_BASE}/auth/login/`, { username, password });
localStorage.setItem('token', res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user));
navigate('/meetings');
} catch (error) {
alert('登录失败:' + (error.response?.data?.detail || error.message));
}
};
return (
<div style={styles.center}>
<div style={styles.card}>
<h1 style={styles.title}>🏛 龙虾议事厅</h1>
<form onSubmit={handleLogin} style={styles.form}>
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} style={styles.input} required />
<input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} style={styles.input} required />
<button type="submit" style={styles.btn}>登录</button>
</form>
</div>
</div>
);
}
// ============ 会议列表 ============
function MeetingList() {
const [meetings, setMeetings] = useState([]);
const [topic, setTopic] = useState('');
const navigate = useNavigate();
const token = localStorage.getItem('token');
useEffect(() => {
if (!token) { navigate('/login'); return; }
fetchMeetings();
}, []);
const fetchMeetings = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/`);
setMeetings(res.data);
} catch (error) { console.error(error); }
};
const createMeeting = async (e) => {
e.preventDefault();
try {
const res = await axios.post(`${API_BASE}/meetings/`, { topic });
navigate(`/meeting/${res.data.id}`);
} catch (error) {
alert('创建失败:' + (error.response?.data?.detail || error.message));
}
};
const logout = () => { localStorage.removeItem('token'); navigate('/login'); };
return (
<div style={styles.container}>
<div style={styles.header}>
<h1>📋 我的会议室</h1>
<button onClick={logout} style={styles.smallBtn}>退出</button>
</div>
<div style={styles.card}>
<h2>创建会议</h2>
<form onSubmit={createMeeting} style={styles.form}>
<input type="text" placeholder="会议主题" value={topic} onChange={e => setTopic(e.target.value)} style={styles.input} required />
<button type="submit" style={styles.btn}>创建</button>
</form>
</div>
<div style={styles.list}>
{meetings.map(m => (
<div key={m.id} style={styles.item}>
<div>
<h3>{m.topic}</h3>
<p>状态{m.status} | 邀请码{m.invite_code}</p>
</div>
<button onClick={() => navigate(`/meeting/${m.id}`)} style={styles.smallBtn}>进入</button>
</div>
))}
</div>
</div>
);
}
// ============ 会议室 ============
function MeetingRoom() {
const { id } = useParams();
const [messages, setMessages] = useState([]);
const [content, setContent] = useState('');
const [participants, setParticipants] = useState([]);
const [meeting, setMeeting] = useState(null);
const [hoveredSeat, setHoveredSeat] = useState(null);
const token = localStorage.getItem('token');
useEffect(() => {
if (!token) return;
fetchMeeting();
fetchParticipants();
fetchMessages();
const interval = setInterval(fetchMessages, 1000);
return () => clearInterval(interval);
}, [id]);
const fetchMeeting = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data);
// 获取会议信息后,自动加入会议
if (res.data.invite_code) {
joinMeeting(res.data.invite_code);
}
} catch (error) { console.error(error); }
};
const joinMeeting = async (inviteCode) => {
try {
await axios.post(`${API_BASE}/meetings/${id}/join/`, {
invite_code: inviteCode
});
// 加入后刷新参会者列表
fetchParticipants();
} catch (error) {
// 可能已经加入了,忽略错误
console.log('加入会议:', error?.response?.data?.error || '已加入');
}
};
const fetchParticipants = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
setParticipants(res.data);
} catch (error) { console.error(error); }
};
const fetchMessages = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/messages/?last_id=0`);
setMessages(res.data.messages || []);
} catch (error) { console.error(error); }
};
const sendMessage = async (e) => {
e.preventDefault();
if (!content.trim()) return;
try {
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, { content });
setContent('');
fetchMessages();
} catch (error) {
alert('发送失败:' + (error.response?.data?.detail || error.message));
}
};
return (
<div style={styles.container}>
<div style={styles.header}>
<Link to="/meetings" style={styles.link}> 返回</Link>
<h1>{meeting?.topic || '会议室'}</h1>
</div>
{/* 座位图 */}
<div style={styles.card}>
<h2>🪑 座位图 <span style={styles.badge}>{participants.length}</span></h2>
<div style={styles.seats}>
{participants.map(p => (
<div
key={p.id}
style={{
...styles.seat,
...(hoveredSeat === p.id ? styles.seatHover : {})
}}
onMouseEnter={() => setHoveredSeat(p.id)}
onMouseLeave={() => setHoveredSeat(null)}
title={p.nickname}
>
<div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
<div style={styles.seatName}>{p.nickname}</div>
{p.is_host && <div style={styles.hostBadge}>👑</div>}
</div>
))}
</div>
</div>
{/* 聊天 */}
<div style={styles.card}>
<h2>💬 聊天 <span style={styles.badge}>{messages.length}</span></h2>
<div style={styles.messages}>
{messages.map(msg => (
<div key={msg.id} style={styles.msg}>
<div style={styles.msgHeader}>
<strong>{msg.sender_emoji} {msg.sender_name}</strong>
<span style={styles.msgTime}>{new Date(msg.created_at).toLocaleTimeString()}</span>
</div>
<p style={styles.msgContent}>{msg.content}</p>
{msg.in_reply_to && <div style={styles.replyTag}> 回复 #{msg.in_reply_to}</div>}
</div>
))}
</div>
<form onSubmit={sendMessage} style={styles.form}>
<input type="text" placeholder="输入消息..." value={content} onChange={e => setContent(e.target.value)} style={styles.input} />
<button type="submit" style={styles.btn}>发送</button>
</form>
</div>
</div>
);
}
// ============ App ============
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/meetings" element={<MeetingList />} />
<Route path="/meeting/:id" element={<MeetingRoom />} />
<Route path="/" element={<LoginPage />} />
</Routes>
</Router>
);
}
// ============ 样式 ============
const styles = {
center: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
container: { maxWidth: '900px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
header: { display: 'flex', alignItems: 'center', gap: '15px', marginBottom: '20px' },
card: { background: 'white', borderRadius: '12px', padding: '20px', marginBottom: '20px', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
title: { margin: '0 0 20px', color: '#1a365d', textAlign: 'center' },
form: { display: 'flex', gap: '10px' },
input: { flex: 1, padding: '12px', border: '2px solid #e2e8f0', borderRadius: '8px', fontSize: '14px' },
btn: { padding: '12px 20px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: '600' },
smallBtn: { padding: '8px 16px', background: '#edf2f7', border: 'none', borderRadius: '6px', cursor: 'pointer' },
list: { display: 'flex', flexDirection: 'column', gap: '15px' },
item: { background: 'white', borderRadius: '12px', padding: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
link: { color: '#4299e1', textDecoration: 'none', fontSize: '16px' },
badge: { background: '#667eea', color: 'white', padding: '4px 10px', borderRadius: '20px', fontSize: '12px', fontWeight: '600' },
seats: { display: 'flex', flexWrap: 'wrap', gap: '15px', justifyContent: 'center' },
seat: { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', padding: '15px', borderRadius: '50%', width: '90px', height: '90px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', cursor: 'default', transition: 'transform 0.2s' },
seatHover: { transform: 'scale(1.05)' },
seatEmoji: { fontSize: '28px', marginBottom: '5px' },
seatName: { fontSize: '12px', fontWeight: '600' },
hostBadge: { fontSize: '10px', opacity: '0.8' },
messages: { maxHeight: '400px', overflowY: 'auto', marginBottom: '15px' },
msg: { padding: '12px', background: '#f7fafc', borderRadius: '8px', marginBottom: '10px' },
msgHeader: { display: 'flex', justifyContent: 'space-between', marginBottom: '5px' },
msgContent: { margin: '5px 0', color: '#4a5568' },
msgTime: { fontSize: '12px', color: '#a0aec0' },
replyTag: { fontSize: '11px', color: '#a0aec0', marginTop: '5px' }
};
export default App;