⚛️ 飞行侠完成:React 前端

完整功能:
- 登录/登出
- 会议列表/创建
- 会议室界面
  - 座位图(圆形头像)
  - 实时消息(1 秒轮询)
  - @Agent 功能
  - 会议控制(开始/结束)
  - 导出会议纪要

文件:
- frontend/src/App.js: 完整重写

访问:
- React 前端:http://localhost:3000/
- Django 后端:http://localhost:8000/
- 默认账号:test / test123
This commit is contained in:
2026-04-04 11:49:54 +08:00
parent 9382892ac7
commit 5d963af156
2 changed files with 175 additions and 263 deletions

View File

@@ -16000,9 +16000,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.2", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"bin": { "bin": {
@@ -16010,7 +16010,7 @@
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=4.2.0"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {

View File

@@ -4,28 +4,24 @@ import axios from 'axios';
const API_BASE = 'http://localhost:8000/api/v1'; const API_BASE = 'http://localhost:8000/api/v1';
// 配置 axios 默认头
axios.interceptors.request.use(config => { axios.interceptors.request.use(config => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`;
}
return config; return config;
}); });
// ============ 登录页面 ============ // ============ 登录页面 ============
function LoginPage() { function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('test');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('test123');
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogin = async (e) => { const handleLogin = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await axios.post(`${API_BASE}/auth/login/`, { username, password }); const res = await axios.post(`${API_BASE}/auth/login/`, { username, password });
const token = response.data.token; localStorage.setItem('token', res.data.token);
localStorage.setItem('token', token); localStorage.setItem('user', JSON.stringify(res.data.user));
localStorage.setItem('user', JSON.stringify(response.data.user));
navigate('/meetings'); navigate('/meetings');
} catch (error) { } catch (error) {
alert('登录失败:' + (error.response?.data?.detail || error.message)); alert('登录失败:' + (error.response?.data?.detail || error.message));
@@ -33,35 +29,20 @@ function LoginPage() {
}; };
return ( return (
<div style={styles.container}> <div style={styles.center}>
<div style={styles.card}> <div style={styles.card}>
<h1 style={styles.title}>🏛 龙虾议事厅</h1> <h1 style={styles.title}>🏛 龙虾议事厅</h1>
<form onSubmit={handleLogin} style={styles.form}> <form onSubmit={handleLogin} style={styles.form}>
<input <input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} style={styles.input} required />
type="text" <input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} style={styles.input} required />
placeholder="用户名" <button type="submit" style={styles.btn}>登录</button>
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.button}>登录</button>
</form> </form>
<p style={styles.hint}>提示首次使用请先注册超级用户</p>
</div> </div>
</div> </div>
); );
} }
// ============ 会议列表页面 ============ // ============ 会议列表 ============
function MeetingList() { function MeetingList() {
const [meetings, setMeetings] = useState([]); const [meetings, setMeetings] = useState([]);
const [topic, setTopic] = useState(''); const [topic, setTopic] = useState('');
@@ -69,92 +50,50 @@ function MeetingList() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
useEffect(() => { useEffect(() => {
if (!token) { if (!token) { navigate('/login'); return; }
navigate('/login');
return;
}
fetchMeetings(); fetchMeetings();
}, []); }, []);
const fetchMeetings = async () => { const fetchMeetings = async () => {
try { try {
const response = await axios.get(`${API_BASE}/meetings/`, { const res = await axios.get(`${API_BASE}/meetings/`);
headers: { Authorization: `Bearer ${token}` } setMeetings(res.data);
}); } catch (error) { console.error(error); }
setMeetings(response.data);
} catch (error) {
console.error('获取会议失败:', error);
}
}; };
const createMeeting = async (e) => { const createMeeting = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await axios.post( const res = await axios.post(`${API_BASE}/meetings/`, { topic });
`${API_BASE}/meetings/`, navigate(`/meeting/${res.data.id}`);
{ topic },
{ headers: { Authorization: `Bearer ${token}` } }
);
navigate(`/meeting/${response.data.id}`);
} catch (error) { } catch (error) {
alert('创建失败:' + (error.response?.data?.detail || error.message)); alert('创建失败:' + (error.response?.data?.detail || error.message));
} }
}; };
const joinMeeting = async (meetingId) => { const logout = () => { localStorage.removeItem('token'); navigate('/login'); };
const inviteCode = prompt('请输入邀请码:');
if (!inviteCode) return;
try {
await axios.post(
`${API_BASE}/meetings/${meetingId}/join/`,
{ invite_code: inviteCode },
{ headers: { Authorization: `Bearer ${token}` } }
);
navigate(`/meeting/${meetingId}`);
} catch (error) {
alert('加入失败:' + (error.response?.data?.error || error.message));
}
};
const logout = () => {
localStorage.removeItem('token');
navigate('/login');
};
return ( return (
<div style={styles.container}> <div style={styles.container}>
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>🏛 我的会议室</h1> <h1>📋 我的会议室</h1>
<button onClick={logout} style={styles.logoutBtn}>退出</button> <button onClick={logout} style={styles.smallBtn}>退出</button>
</div> </div>
<div style={styles.card}> <div style={styles.card}>
<h2>创建会议</h2> <h2>创建会议</h2>
<form onSubmit={createMeeting} style={styles.form}> <form onSubmit={createMeeting} style={styles.form}>
<input <input type="text" placeholder="会议主题" value={topic} onChange={e => setTopic(e.target.value)} style={styles.input} required />
type="text" <button type="submit" style={styles.btn}>创建</button>
placeholder="会议主题"
value={topic}
onChange={(e) => setTopic(e.target.value)}
style={styles.input}
required
/>
<button type="submit" style={styles.button}>创建</button>
</form> </form>
</div> </div>
<div style={styles.list}>
<div style={styles.meetingList}> {meetings.map(m => (
{meetings.map(meeting => ( <div key={m.id} style={styles.item}>
<div key={meeting.id} style={styles.meetingCard}>
<div> <div>
<h3>{meeting.topic}</h3> <h3>{m.topic}</h3>
<p>状态{meeting.status} | 参会{meeting.participant_count} | 邀请码{meeting.invite_code}</p> <p>状态{m.status} | 邀请码{m.invite_code}</p>
</div>
<div>
<button onClick={() => navigate(`/meeting/${meeting.id}`)} style={styles.smallBtn}>进入</button>
<button onClick={() => joinMeeting(meeting.id)} style={styles.smallBtn}>加入</button>
</div> </div>
<button onClick={() => navigate(`/meeting/${m.id}`)} style={styles.smallBtn}>进入</button>
</div> </div>
))} ))}
</div> </div>
@@ -162,58 +101,50 @@ function MeetingList() {
); );
} }
// ============ 会议室页面 ============ // ============ 会议室 ============
function MeetingRoom() { function MeetingRoom() {
const { id } = useParams(); const { id } = useParams();
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [participants, setParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const [lastId, setLastId] = useState(0); const [meeting, setMeeting] = useState(null);
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
fetchMeeting();
fetchParticipants(); fetchParticipants();
fetchMessages(); fetchMessages();
// 1 秒轮询新消息
const interval = setInterval(fetchMessages, 1000); const interval = setInterval(fetchMessages, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [id]);
const fetchMeeting = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data);
} catch (error) { console.error(error); }
};
const fetchParticipants = async () => { const fetchParticipants = async () => {
try { try {
const response = await axios.get(`${API_BASE}/meetings/${id}/participants/`, { const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
headers: { Authorization: `Bearer ${token}` } setParticipants(res.data);
}); } catch (error) { console.error(error); }
setParticipants(response.data);
} catch (error) {
console.error('获取参会者失败:', error);
}
}; };
const fetchMessages = async () => { const fetchMessages = async () => {
try { try {
const response = await axios.get(`${API_BASE}/meetings/${id}/messages/?last_id=${lastId}`, { const res = await axios.get(`${API_BASE}/meetings/${id}/messages/?last_id=0`);
headers: { Authorization: `Bearer ${token}` } setMessages(res.data.messages || []);
}); } catch (error) { console.error(error); }
if (response.data.messages.length > 0) {
setMessages(prev => [...prev, ...response.data.messages]);
setLastId(response.data.messages[response.data.messages.length - 1].id);
}
} catch (error) {
console.error('获取消息失败:', error);
}
}; };
const sendMessage = async (e) => { const sendMessage = async (e) => {
e.preventDefault(); e.preventDefault();
if (!content.trim()) return;
try { try {
await axios.post( await axios.post(`${API_BASE}/meetings/${id}/send_message/`, { content });
`${API_BASE}/meetings/${id}/send_message/`,
{ content },
{ headers: { Authorization: `Bearer ${token}` } }
);
setContent(''); setContent('');
fetchMessages(); fetchMessages();
} catch (error) { } catch (error) {
@@ -221,48 +152,119 @@ function MeetingRoom() {
} }
}; };
const mentionAgent = async () => {
const targetAgentId = prompt('@哪个 Agent输入 agent_id:');
if (!targetAgentId || !content.trim()) return;
try {
await axios.post(`${API_BASE}/meetings/${id}/mention_agent/`, {
target_agent_id: targetAgentId, content,
sender_name: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')).username : 'User'
});
setContent('');
fetchMessages();
alert(`✅ 已 @${targetAgentId}`);
} catch (error) {
alert('发送失败:' + (error.response?.data?.error || error.message));
}
};
const startMeeting = async () => {
try {
await axios.post(`${API_BASE}/meetings/${id}/start/`);
fetchMeeting();
alert('✅ 会议已开始');
} catch (error) {
alert('开始失败:' + (error.response?.data?.error || error.message));
}
};
const endMeeting = async () => {
if (!confirm('确定结束会议?')) return;
try {
await axios.post(`${API_BASE}/meetings/${id}/end/`);
fetchMeeting();
alert('✅ 会议已结束');
} catch (error) {
alert('结束失败:' + (error.response?.data?.error || error.message));
}
};
const generateMinutes = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/minutes/?output=markdown`);
const blob = new Blob([res.data.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meeting-${id.slice(0, 8)}.md`;
a.click();
URL.revokeObjectURL(url);
alert('✅ 纪要已导出');
} catch (error) {
alert('导出失败:' + error.message);
}
};
return ( return (
<div style={styles.container}> <div style={styles.container}>
<div style={styles.header}> <div style={styles.header}>
<Link to="/meetings" style={styles.backLink}> 返回</Link> <Link to="/meetings" style={styles.link}> 返回</Link>
<h1 style={styles.title}>🏛 会议室</h1> <h1>{meeting?.topic || '会议室'}</h1>
<div style={styles.participants}> {meeting && <span style={styles.badge}>{meeting.status}</span>}
</div>
{meeting && (
<div style={styles.infoCard}>
<p><strong>ID:</strong> {meeting.id}</p>
<p><strong>邀请码:</strong> {meeting.invite_code}</p>
<div style={styles.btnGroup}>
<button onClick={startMeeting} style={styles.btnGreen}> 开始</button>
<button onClick={endMeeting} style={styles.btnRed}> 结束</button>
<button onClick={generateMinutes} style={styles.btnBlue}>📋 纪要</button>
</div>
</div>
)}
{/* 座位图 */}
<div style={styles.card}>
<h2>🪑 座位图 <span style={styles.badge}>{participants.length}</span></h2>
<div style={styles.seats}>
{participants.map(p => ( {participants.map(p => (
<span key={p.id} style={styles.participant}> <div key={p.id} style={styles.seat}>
{p.agent_emoji} {p.nickname} <div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
</span> <div style={styles.seatName}>{p.nickname}</div>
{p.is_host && <div style={styles.hostBadge}>👑</div>}
</div>
))} ))}
</div> </div>
</div> </div>
<div style={styles.chatContainer}> {/* 聊天 */}
<div style={styles.card}>
<h2>💬 聊天 <span style={styles.badge}>{messages.length}</span></h2>
<div style={styles.messages}> <div style={styles.messages}>
{messages.map(msg => ( {messages.map(msg => (
<div key={msg.id} style={styles.message}> <div key={msg.id} style={styles.msg}>
<div style={styles.msgHeader}>
<strong>{msg.sender_emoji} {msg.sender_name}</strong> <strong>{msg.sender_emoji} {msg.sender_name}</strong>
<span style={styles.time}>{new Date(msg.created_at).toLocaleTimeString()}</span> <span style={styles.msgTime}>{new Date(msg.created_at).toLocaleTimeString()}</span>
<p>{msg.content}</p> </div>
<p style={styles.msgContent}>{msg.content}</p>
{msg.in_reply_to && <div style={styles.replyTag}> 回复 #{msg.in_reply_to}</div>}
</div> </div>
))} ))}
</div> </div>
<form onSubmit={sendMessage} style={styles.form}> <form onSubmit={sendMessage} style={styles.form}>
<input <input type="text" placeholder="输入消息..." value={content} onChange={e => setContent(e.target.value)} style={styles.input} />
type="text" <button type="submit" style={styles.btn}>发送</button>
placeholder="输入消息..." <button type="button" onClick={mentionAgent} style={styles.btnPink}>📍 @Agent</button>
value={content}
onChange={(e) => setContent(e.target.value)}
style={styles.input}
required
/>
<button type="submit" style={styles.button}>发送</button>
</form> </form>
</div> </div>
</div> </div>
); );
} }
// ============ 主应用 ============ // ============ App ============
function App() { function App() {
return ( return (
<Router> <Router>
@@ -278,126 +280,36 @@ function App() {
// ============ 样式 ============ // ============ 样式 ============
const styles = { const styles = {
container: { center: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
maxWidth: '1200px', container: { maxWidth: '900px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
margin: '0 auto', header: { display: 'flex', alignItems: 'center', gap: '15px', marginBottom: '20px' },
padding: '20px', card: { background: 'white', borderRadius: '12px', padding: '20px', marginBottom: '20px', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' infoCard: { background: '#e7f3ff', border: '1px solid #2196f3', borderRadius: '12px', padding: '15px', marginBottom: '20px' },
}, title: { margin: '0 0 20px', color: '#1a365d', textAlign: 'center' },
header: { form: { display: 'flex', gap: '10px' },
display: 'flex', input: { flex: 1, padding: '12px', border: '2px solid #e2e8f0', borderRadius: '8px', fontSize: '14px' },
alignItems: 'center', btn: { padding: '12px 20px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: '600' },
gap: '20px', btnGreen: { padding: '8px 16px', background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', marginRight: '8px' },
marginBottom: '20px' btnRed: { padding: '8px 16px', background: 'linear-gradient(135deg, #eb3349 0%, #f45c43 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', marginRight: '8px' },
}, btnBlue: { padding: '8px 16px', background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
title: { btnPink: { padding: '8px 16px', background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
margin: '0', smallBtn: { padding: '8px 16px', background: '#edf2f7', border: 'none', borderRadius: '6px', cursor: 'pointer' },
color: '#1a365d' 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)' },
card: { link: { color: '#4299e1', textDecoration: 'none', fontSize: '16px' },
background: 'white', badge: { background: '#667eea', color: 'white', padding: '4px 10px', borderRadius: '20px', fontSize: '12px', fontWeight: '600' },
borderRadius: '12px', btnGroup: { display: 'flex', marginTop: '10px' },
padding: '20px', seats: { display: 'flex', flexWrap: 'wrap', gap: '15px', justifyContent: 'center' },
marginBottom: '20px', 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' },
boxShadow: '0 4px 6px rgba(0,0,0,0.1)' seatEmoji: { fontSize: '28px', marginBottom: '5px' },
}, seatName: { fontSize: '12px', fontWeight: '600' },
form: { hostBadge: { fontSize: '10px', opacity: '0.8' },
display: 'flex', messages: { maxHeight: '400px', overflowY: 'auto', marginBottom: '15px' },
gap: '10px', msg: { padding: '12px', background: '#f7fafc', borderRadius: '8px', marginBottom: '10px' },
marginBottom: '15px' msgHeader: { display: 'flex', justifyContent: 'space-between', marginBottom: '5px' },
}, msgContent: { margin: '5px 0', color: '#4a5568' },
input: { msgTime: { fontSize: '12px', color: '#a0aec0' },
flex: 1, replyTag: { fontSize: '11px', color: '#a0aec0', marginTop: '5px' }
padding: '12px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
fontSize: '1em'
},
button: {
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '1em',
fontWeight: '600'
},
logoutBtn: {
marginLeft: 'auto',
padding: '8px 16px',
background: '#edf2f7',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
},
meetingList: {
display: 'flex',
flexDirection: 'column',
gap: '15px'
},
meetingCard: {
background: 'white',
borderRadius: '12px',
padding: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
},
smallBtn: {
padding: '8px 16px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
marginLeft: '10px'
},
chatContainer: {
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
},
messages: {
maxHeight: '500px',
overflowY: 'auto',
marginBottom: '20px'
},
message: {
padding: '15px',
background: '#f7fafc',
borderRadius: '8px',
marginBottom: '10px'
},
time: {
fontSize: '0.8em',
color: '#718096',
marginLeft: '10px'
},
participants: {
display: 'flex',
gap: '10px',
marginLeft: 'auto'
},
participant: {
background: '#edf2f7',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '0.9em'
},
backLink: {
color: '#4299e1',
textDecoration: 'none',
fontSize: '1.1em'
},
hint: {
color: '#718096',
fontSize: '0.9em',
textAlign: 'center',
marginTop: '15px'
}
}; };
export default App; export default App;