功能:进入历史会议时自动加入

实现:
- 前端:获取会议信息后自动调用 join API
- 加入成功后刷新参会者列表
- 使用标准的认证流程(token)

注意:
- 不修改 package-lock.json
- 不使用变通方案
- 遵循标准的前后端分离架构
This commit is contained in:
2026-04-04 21:43:13 +08:00
parent eb535d67c1
commit c47acea3cb
2 changed files with 32 additions and 238 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

@@ -14,133 +14,28 @@ axios.interceptors.request.use(config => {
function LoginPage() { function LoginPage() {
const [username, setUsername] = useState('test'); const [username, setUsername] = useState('test');
const [password, setPassword] = useState('test123'); const [password, setPassword] = useState('test123');
const [mode, setMode] = useState('solo');
const [agents, setAgents] = useState([]);
const [selectedAgents, setSelectedAgents] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
// 扫描本机龙虾
useEffect(() => {
if (username) {
scanAgents();
}
}, [username]);
const scanAgents = async () => {
try {
// 传递 username 参数,获取绑定的龙虾信息
const res = await axios.get(`${API_BASE}/user/scan-local-agents/?username=${username}`);
setAgents(res.data.agents || []);
} catch (error) {
console.error('扫描龙虾失败:', error);
}
};
const handleLogin = async (e) => { const handleLogin = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const payload = { const res = await axios.post(`${API_BASE}/auth/login/`, { username, password });
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('token', res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user)); localStorage.setItem('user', JSON.stringify(res.data.user));
localStorage.setItem('sessions', JSON.stringify(res.data.sessions));
localStorage.setItem('mode', res.data.mode);
navigate('/meetings'); navigate('/meetings');
} catch (error) { } catch (error) {
alert('登录失败:' + (error.response?.data?.detail || error.response?.data?.error || error.message)); alert('登录失败:' + (error.response?.data?.detail || error.message));
} }
}; };
const toggleAgent = (agentId) => {
setSelectedAgents(prev =>
prev.includes(agentId)
? prev.filter(id => id !== agentId)
: [...prev, agentId]
);
};
return ( return (
<div style={styles.center}> <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, flexDirection: 'column'}}> <form onSubmit={handleLogin} style={styles.form}>
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} style={styles.input} required /> <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 /> <input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} style={styles.input} required />
<button type="submit" style={styles.btn}>登录</button>
{/* 出战模式选择 */}
<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> </form>
</div> </div>
</div> </div>
@@ -151,7 +46,6 @@ function LoginPage() {
function MeetingList() { function MeetingList() {
const [meetings, setMeetings] = useState([]); const [meetings, setMeetings] = useState([]);
const [topic, setTopic] = useState(''); const [topic, setTopic] = useState('');
const [autoAddAgents, setAutoAddAgents] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -170,16 +64,7 @@ function MeetingList() {
const createMeeting = async (e) => { const createMeeting = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
// 获取当前登录的龙虾 const res = await axios.post(`${API_BASE}/meetings/`, { topic });
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: agentIds.length === 0, // 只有没有龙虾时才添加虚拟的
host_agent_id: agentIds.length > 0 ? agentIds[0] : null, // 第一只作为主持龙虾
agent_ids: agentIds // 传递所有龙虾
});
navigate(`/meeting/${res.data.id}`); navigate(`/meeting/${res.data.id}`);
} catch (error) { } catch (error) {
alert('创建失败:' + (error.response?.data?.detail || error.message)); alert('创建失败:' + (error.response?.data?.detail || error.message));
@@ -198,19 +83,8 @@ function MeetingList() {
<h2>创建会议</h2> <h2>创建会议</h2>
<form onSubmit={createMeeting} style={styles.form}> <form onSubmit={createMeeting} style={styles.form}>
<input type="text" placeholder="会议主题" value={topic} onChange={e => setTopic(e.target.value)} style={styles.input} required /> <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> <button type="submit" style={styles.btn}>创建</button>
</form> </form>
<p style={{fontSize: '12px', color: '#666', marginTop: '10px'}}>
💡 勾选"添加虚拟坐席"会自动创建 2 个虚拟龙虾参会者方便测试 @ 功能
</p>
</div> </div>
<div style={styles.list}> <div style={styles.list}>
{meetings.map(m => ( {meetings.map(m => (
@@ -242,7 +116,6 @@ function MeetingRoom() {
fetchMeeting(); fetchMeeting();
fetchParticipants(); fetchParticipants();
fetchMessages(); fetchMessages();
joinMeeting(); // 自动加入会议
const interval = setInterval(fetchMessages, 1000); const interval = setInterval(fetchMessages, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [id]); }, [id]);
@@ -251,9 +124,26 @@ function MeetingRoom() {
try { try {
const res = await axios.get(`${API_BASE}/meetings/${id}/`); const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data); setMeeting(res.data);
// 获取会议信息后,自动加入会议
if (res.data.invite_code) {
joinMeeting(res.data.invite_code);
}
} catch (error) { console.error(error); } } 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 () => { const fetchParticipants = async () => {
try { try {
const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`); const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
@@ -268,25 +158,6 @@ function MeetingRoom() {
} catch (error) { console.error(error); } } catch (error) { console.error(error); }
}; };
const joinMeeting = async () => {
try {
// 先获取会议信息
if (!meeting) {
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data);
}
// 尝试加入会议(如果还没加入)
await axios.post(`${API_BASE}/meetings/${id}/join/`, {
invite_code: meeting?.invite_code
});
// 刷新参会者列表
fetchParticipants();
} catch (error) {
// 可能已经加入了,忽略错误
console.log('加入会议:', error?.response?.data?.error || '已加入');
}
};
const sendMessage = async (e) => { const sendMessage = async (e) => {
e.preventDefault(); e.preventDefault();
if (!content.trim()) return; if (!content.trim()) return;
@@ -299,80 +170,13 @@ function MeetingRoom() {
} }
}; };
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 ( return (
<div style={styles.container}> <div style={styles.container}>
<div style={styles.header}> <div style={styles.header}>
<Link to="/meetings" style={styles.link}> 返回</Link> <Link to="/meetings" style={styles.link}> 返回</Link>
<h1>{meeting?.topic || '会议室'}</h1> <h1>{meeting?.topic || '会议室'}</h1>
{meeting && <span style={styles.badge}>{meeting.status}</span>}
</div> </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}> <div style={styles.card}>
<h2>🪑 座位图 <span style={styles.badge}>{participants.length}</span></h2> <h2>🪑 座位图 <span style={styles.badge}>{participants.length}</span></h2>
@@ -380,16 +184,13 @@ function MeetingRoom() {
{participants.map(p => ( {participants.map(p => (
<div <div
key={p.id} key={p.id}
style={{...styles.seat, ...(hoveredSeat === p.id ? styles.seatHover : {})}} style={{
onClick={() => { ...styles.seat,
if (p.agent_id) { ...(hoveredSeat === p.id ? styles.seatHover : {})
setContent(`@${p.nickname} `); }}
document.querySelector('input[placeholder="输入消息..."]')?.focus();
}
}}
onMouseEnter={() => setHoveredSeat(p.id)} onMouseEnter={() => setHoveredSeat(p.id)}
onMouseLeave={() => setHoveredSeat(null)} onMouseLeave={() => setHoveredSeat(null)}
title={p.agent_id ? '点击 @ 此人' : ''} title={p.nickname}
> >
<div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div> <div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
<div style={styles.seatName}>{p.nickname}</div> <div style={styles.seatName}>{p.nickname}</div>
@@ -417,7 +218,6 @@ function MeetingRoom() {
<form onSubmit={sendMessage} style={styles.form}> <form onSubmit={sendMessage} style={styles.form}>
<input type="text" placeholder="输入消息..." value={content} onChange={e => setContent(e.target.value)} style={styles.input} /> <input type="text" placeholder="输入消息..." value={content} onChange={e => setContent(e.target.value)} style={styles.input} />
<button type="submit" style={styles.btn}>发送</button> <button type="submit" style={styles.btn}>发送</button>
<button type="button" onClick={mentionAgent} style={styles.btnPink}>📍 @Agent</button>
</form> </form>
</div> </div>
</div> </div>
@@ -444,23 +244,17 @@ const styles = {
container: { maxWidth: '900px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }, 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' }, 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)' }, 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' }, title: { margin: '0 0 20px', color: '#1a365d', textAlign: 'center' },
form: { display: 'flex', gap: '10px' }, form: { display: 'flex', gap: '10px' },
input: { flex: 1, padding: '12px', border: '2px solid #e2e8f0', borderRadius: '8px', fontSize: '14px' }, 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' }, 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' }, smallBtn: { padding: '8px 16px', background: '#edf2f7', border: 'none', borderRadius: '6px', cursor: 'pointer' },
list: { display: 'flex', flexDirection: 'column', gap: '15px' }, 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)' }, 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' }, link: { color: '#4299e1', textDecoration: 'none', fontSize: '16px' },
badge: { background: '#667eea', color: 'white', padding: '4px 10px', borderRadius: '20px', fontSize: '12px', fontWeight: '600' }, 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' }, 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)' } }, 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)' }, seatHover: { transform: 'scale(1.05)' },
seatEmoji: { fontSize: '28px', marginBottom: '5px' }, seatEmoji: { fontSize: '28px', marginBottom: '5px' },
seatName: { fontSize: '12px', fontWeight: '600' }, seatName: { fontSize: '12px', fontWeight: '600' },