Files
meeting-room/frontend/src/App.js
flying-hero c47acea3cb 功能:进入历史会议时自动加入
实现:
- 前端:获取会议信息后自动调用 join API
- 加入成功后刷新参会者列表
- 使用标准的认证流程(token)

注意:
- 不修改 package-lock.json
- 不使用变通方案
- 遵循标准的前后端分离架构
2026-04-04 21:43:13 +08:00

271 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;