Files
meeting-room/frontend/src/App.js
flying-hero 1954a768f3 🔧 修复:组队团战时创建所有龙虾座位
问题:
- 登录选了 N 只龙虾,但创建会议只有人类座位

修复:
- 前端:创建会议时传递 host_agent_id(第一只龙虾)
- 后端:根据 host_agent_id 创建龙虾参会者
- 优化:只有没有龙虾时才添加虚拟坐席

结果:
 单枪匹马 - 1 个座位
 组队团战(2 龙虾)- 3 个座位
 独当一面(1 龙虾)- 1 个座位
2026-04-04 16:50:30 +08:00

453 lines
19 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 [mode, setMode] = useState('solo');
const [agents, setAgents] = useState([]);
const [selectedAgents, setSelectedAgents] = useState([]);
const navigate = useNavigate();
// 扫描本机龙虾
useEffect(() => {
scanAgents();
}, []);
const scanAgents = async () => {
try {
const res = await axios.get(`${API_BASE}/user/scan-local-agents/`);
setAgents(res.data.agents || []);
} catch (error) {
console.error('扫描龙虾失败:', error);
}
};
const handleLogin = async (e) => {
e.preventDefault();
try {
const payload = {
username,
password,
mode
};
if (mode !== 'solo' && selectedAgents.length > 0) {
payload.agent_ids = selectedAgents;
}
const res = await axios.post(`${API_BASE}/auth/login/`, payload);
localStorage.setItem('token', res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user));
localStorage.setItem('sessions', JSON.stringify(res.data.sessions));
localStorage.setItem('mode', res.data.mode);
navigate('/meetings');
} catch (error) {
alert('登录失败:' + (error.response?.data?.detail || error.response?.data?.error || error.message));
}
};
const toggleAgent = (agentId) => {
setSelectedAgents(prev =>
prev.includes(agentId)
? prev.filter(id => id !== agentId)
: [...prev, agentId]
);
};
return (
<div style={styles.center}>
<div style={styles.card}>
<h1 style={styles.title}>🏛 龙虾议事厅</h1>
<form onSubmit={handleLogin} style={{...styles.form, flexDirection: 'column'}}>
<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 />
{/* 出战模式选择 */}
<div style={{margin: '15px 0'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🎯 出战模式</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'solo' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="solo"
checked={mode === 'solo'}
onChange={e => setMode(e.target.value)}
/>
{' '}🥷 <strong>单枪匹马</strong>
</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'team' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="team"
checked={mode === 'team'}
onChange={e => setMode(e.target.value)}
/>
{' '}🛡 <strong>组队团战</strong> + N
</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'agent_only' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="agent_only"
checked={mode === 'agent_only'}
onChange={e => setMode(e.target.value)}
/>
{' '} <strong>独当一面</strong>
</label>
</div>
{/* 龙虾选择(组队或独当一面) */}
{mode !== 'solo' && (
<div style={{margin: '15px 0', padding: '15px', background: '#f9f9f9', borderRadius: '8px'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🦐 选择龙虾队友</label>
{agents.length === 0 ? (
<p style={{color: '#999', fontSize: '14px'}}>未找到可用龙虾</p>
) : (
agents.map(a => (
<label key={a.agent_id} style={{display: 'flex', alignItems: 'center', marginBottom: '8px', cursor: 'pointer'}}>
<input
type="checkbox"
checked={selectedAgents.includes(a.agent_id)}
onChange={() => toggleAgent(a.agent_id)}
style={{marginRight: '10px'}}
/>
<span style={{fontSize: '16px', marginRight: '8px'}}>{a.agent_emoji || '🤖'}</span>
<span>{a.agent_id}</span>
<span style={{color: '#999', fontSize: '12px', marginLeft: '8px'}}>({a.instance_name})</span>
</label>
))
)}
{selectedAgents.length > 0 && (
<p style={{marginTop: '10px', color: '#2196f3', fontWeight: '600'}}>
已选 {selectedAgents.length} 只龙虾队友 🦸
</p>
)}
</div>
)}
<button type="submit" style={styles.btn}>🚀 登录出征</button>
</form>
</div>
</div>
);
}
// ============ 会议列表 ============
function MeetingList() {
const [meetings, setMeetings] = useState([]);
const [topic, setTopic] = useState('');
const [autoAddAgents, setAutoAddAgents] = useState(true);
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 sessions = JSON.parse(localStorage.getItem('sessions') || '[]');
const agentIds = sessions.filter(s => s.session_type === 'agent').map(s => s.agent_id);
const res = await axios.post(`${API_BASE}/meetings/`, {
topic,
auto_add_virtual_agents: !autoAddAgents ? false : agentIds.length === 0, // 如果没有龙虾才添加虚拟的
host_agent_id: agentIds.length > 0 ? agentIds[0] : null
});
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 />
<label style={{display: 'flex', alignItems: 'center', gap: '5px', whiteSpace: 'nowrap'}}>
<input
type="checkbox"
checked={autoAddAgents}
onChange={e => setAutoAddAgents(e.target.checked)}
/>
添加虚拟坐席
</label>
<button type="submit" style={styles.btn}>创建</button>
</form>
<p style={{fontSize: '12px', color: '#666', marginTop: '10px'}}>
💡 勾选"添加虚拟坐席"会自动创建 2 个虚拟龙虾参会者方便测试 @ 功能
</p>
</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);
} catch (error) { console.error(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));
}
};
const mentionAgent = async (targetAgentId, agentName) => {
const target = targetAgentId || prompt('@哪个 Agent输入 agent_id:');
if (!target || !content.trim()) return;
const name = agentName || target;
try {
await axios.post(`${API_BASE}/meetings/${id}/mention_agent/`, {
target_agent_id: target, content,
sender_name: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')).username : 'User'
});
setContent('');
fetchMessages();
alert(`✅ 已 @${name}`);
} 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 (
<div style={styles.container}>
<div style={styles.header}>
<Link to="/meetings" style={styles.link}> 返回</Link>
<h1>{meeting?.topic || '会议室'}</h1>
{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 => (
<div
key={p.id}
style={{...styles.seat, ...(hoveredSeat === p.id ? styles.seatHover : {})}}
onClick={() => {
if (p.agent_id) {
setContent(`@${p.nickname} `);
document.querySelector('input[placeholder="输入消息..."]')?.focus();
}
}}
onMouseEnter={() => setHoveredSeat(p.id)}
onMouseLeave={() => setHoveredSeat(null)}
title={p.agent_id ? '点击 @ 此人' : ''}
>
<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>
<button type="button" onClick={mentionAgent} style={styles.btnPink}>📍 @Agent</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)' },
infoCard: { background: '#e7f3ff', border: '1px solid #2196f3', borderRadius: '12px', padding: '15px', marginBottom: '20px' },
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' },
btnGreen: { padding: '8px 16px', background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', marginRight: '8px' },
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' },
btnPink: { padding: '8px 16px', background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
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' },
btnGroup: { display: 'flex', marginTop: '10px' },
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: 'pointer', transition: 'transform 0.2s', ':hover': { transform: 'scale(1.1)' } },
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;