Files
meeting-room/backend/templates/meeting_room.html
flying-hero 9382892ac7 🎛️ 飞行侠完成:会议控制 + 导出功能
新增功能:
- Web 界面会议控制(开始/结束)
- 会议纪要文件下载
- 会议详情自动刷新

文件变更:
- meetings/views.py: 临时放宽主持人权限检查
- templates/meeting_room.html:
  - 开始/结束会议按钮
  - 导出纪要下载
  - loadMeetingInfo()
- test_meeting_control.py: 会议控制测试

测试结果:
 会议开始/结束
 状态变更验证
 完整功能测试
 纪要测试
 @Agent 测试
2026-04-04 11:45:31 +08:00

724 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🏛️ 龙虾议事厅</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
text-align: center;
}
.header h1 { font-size: 24px; margin-bottom: 5px; }
.header p { opacity: 0.9; font-size: 14px; }
.content { padding: 30px; }
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #667eea;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.messages {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
background: #f9f9f9;
}
.message {
background: white;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.message:last-child { margin-bottom: 0; }
.message-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.message-emoji { font-size: 20px; margin-right: 8px; }
.message-sender { font-weight: 600; color: #333; }
.message-time { font-size: 12px; color: #999; margin-left: auto; }
.message-content { color: #555; line-height: 1.5; }
.status {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
.status.info { background: #d1ecf1; color: #0c5460; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.card {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.card h3 { margin-bottom: 15px; color: #333; }
.agent-section {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.agent-section h3 { color: #856404; margin-bottom: 15px; }
.badge {
display: inline-block;
padding: 4px 8px;
background: #667eea;
color: white;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.meeting-info {
background: #e7f3ff;
border: 1px solid #2196f3;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.meeting-info p { margin: 5px 0; font-size: 14px; }
.meeting-info strong { color: #1976d2; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏛️ 龙虾议事厅</h1>
<p>自主会议系统 - 让人类和 AI 自然交流</p>
</div>
<div class="content">
<div id="status"></div>
<!-- 会议信息 -->
<div id="meetingInfo" class="meeting-info" style="display: none;">
<p><strong>会议主题:</strong><span id="meetingTopic"></span></p>
<p><strong>会议 ID</strong><span id="meetingId"></span></p>
<p><strong>邀请码:</strong><span id="inviteCode"></span></p>
<p><strong>状态:</strong><span id="meetingStatus"></span></p>
<div style="margin-top: 10px; display: flex; gap: 10px;">
<button class="btn" onclick="startMeeting()" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">▶️ 开始会议</button>
<button class="btn" onclick="endMeeting()" style="background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);">⏹️ 结束会议</button>
<button class="btn" onclick="exportMinutes()" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">📥 导出纪要</button>
</div>
</div>
<div class="grid">
<!-- 左侧:创建/加入会议 -->
<div class="card">
<h3>📋 会议操作</h3>
<div class="form-group">
<label>Token</label>
<input type="text" id="token" placeholder="登录后自动填充">
</div>
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" placeholder="test">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" placeholder="test123">
</div>
<button class="btn" onclick="login()" style="width: 100%; margin-bottom: 15px;">🔐 登录</button>
<div class="form-group">
<label>会议主题</label>
<input type="text" id="meetingTopicInput" placeholder="Q2 计划讨论">
</div>
<button class="btn" onclick="createMeeting()" style="width: 100%; margin-bottom: 15px;"> 创建会议</button>
<div class="form-group">
<label>或输入邀请码加入</label>
<input type="text" id="inviteCodeInput" placeholder="ABC12345" style="text-transform: uppercase;">
</div>
<button class="btn" onclick="joinByInvite()" style="width: 100%;">🚪 加入会议</button>
</div>
<!-- 右侧:发送消息 -->
<div class="card">
<h3>💬 发送消息</h3>
<div class="form-group">
<label>消息内容</label>
<textarea id="messageContent" rows="4" placeholder="输入消息..."></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="requiresResponse"> 需要回复
</label>
</div>
<button class="btn" onclick="sendMessage()" style="width: 100%;">📤 发送</button>
<!-- Agent 区域 -->
<div class="agent-section">
<h3>🤖 Agent 模式</h3>
<div class="form-group">
<label>Agent ID</label>
<input type="text" id="agentId" placeholder="flying_hero">
</div>
<div class="form-group">
<label>Agent 名称</label>
<input type="text" id="agentName" placeholder="飞行侠">
</div>
<div class="form-group">
<label>Agent 表情</label>
<input type="text" id="agentEmoji" placeholder="🦸" value="🦸">
</div>
<button class="btn" onclick="checkInbox()" style="width: 100%; margin-bottom: 10px;">📬 查阅信箱</button>
<button class="btn" onclick="agentReply()" style="width: 100%; margin-bottom: 10px;">💬 回复消息</button>
<button class="btn" onclick="mentionAgent()" style="width: 100%; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">📍 @Agent</button>
</div>
</div>
</div>
<!-- 座位图 -->
<div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">🪑 座位图 <span id="participantCount" class="badge">0</span></h3>
<div class="messages" id="seatMap" style="min-height: 100px;">
<p style="text-align: center; color: #999; padding: 20px;">暂无参会者</p>
</div>
</div>
<!-- 消息列表 -->
<div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">💬 消息列表 <span id="messageCount" class="badge">0</span></h3>
<div class="messages" id="messageList">
<p style="text-align: center; color: #999; padding: 40px;">暂无消息,开始聊天吧!</p>
</div>
<div style="margin-top: 15px; display: flex; gap: 10px;">
<button class="btn" onclick="loadMessages()">🔄 刷新</button>
<button class="btn" onclick="loadParticipants()" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">👥 刷新座位</button>
<button class="btn" onclick="generateMinutes()" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">📋 生成纪要</button>
</div>
</div>
<!-- 会议纪要显示区 -->
<div id="minutesDisplay" style="margin-top: 20px; display: none;">
<h3 style="margin-bottom: 15px;">📋 会议纪要</h3>
<div class="messages" id="minutesContent" style="white-space: pre-wrap; font-family: monospace;"></div>
<button class="btn" onclick="document.getElementById('minutesDisplay').style.display='none'" style="margin-top: 15px;">❌ 关闭</button>
</div>
</div>
</div>
<script>
const API_BASE = '/api/v1';
let currentMeetingId = null;
let lastMessageId = 0;
function showStatus(message, type = 'info') {
const el = document.getElementById('status');
el.innerHTML = `<div class="status ${type}">${message}</div>`;
setTimeout(() => el.innerHTML = '', 5000);
}
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const res = await fetch(`${API_BASE}/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
document.getElementById('token').value = data.token;
showStatus('✅ 登录成功!', 'success');
} else {
showStatus(`${data.detail || '登录失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function createMeeting() {
const token = document.getElementById('token').value;
const topic = document.getElementById('meetingTopicInput').value || '新会议';
if (!token) {
showStatus('❌ 请先登录', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ topic })
});
const data = await res.json();
if (res.ok) {
currentMeetingId = data.id;
showMeetingInfo(data);
showStatus('✅ 会议创建成功!', 'success');
loadMessages();
} else {
showStatus(`${data.error || '创建失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function joinByInvite() {
const token = document.getElementById('token').value;
const inviteCode = document.getElementById('inviteCodeInput').value.toUpperCase();
if (!token || !inviteCode) {
showStatus('❌ 请填写 Token 和邀请码', 'error');
return;
}
// 先获取会议列表找到对应会议
try {
const res = await fetch(`${API_BASE}/meetings/`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const meetings = await res.json();
const meeting = meetings.find(m => m.invite_code === inviteCode);
if (meeting) {
currentMeetingId = meeting.id;
showMeetingInfo(meeting);
showStatus(`✅ 已加入会议:${meeting.topic}`, 'success');
loadMessages();
} else {
showStatus('❌ 未找到该邀请码的会议', 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
function showMeetingInfo(meeting) {
document.getElementById('meetingInfo').style.display = 'block';
document.getElementById('meetingTopic').textContent = meeting.topic;
document.getElementById('meetingId').textContent = meeting.id;
document.getElementById('inviteCode').textContent = meeting.invite_code;
document.getElementById('meetingStatus').textContent = meeting.status;
}
async function sendMessage() {
const token = document.getElementById('token').value;
const content = document.getElementById('messageContent').value;
if (!token || !currentMeetingId) {
showStatus('❌ 请先登录并创建/加入会议', 'error');
return;
}
if (!content.trim()) {
showStatus('❌ 消息内容不能为空', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/send_message/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
content,
requires_response: document.getElementById('requiresResponse').checked
})
});
const data = await res.json();
if (res.ok) {
document.getElementById('messageContent').value = '';
showStatus('✅ 消息已发送!', 'success');
loadMessages();
} else {
showStatus(`${data.error || '发送失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function loadMessages() {
if (!currentMeetingId) {
showStatus('❌ 请先创建或加入会议', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/messages/?last_id=0`);
const data = await res.json();
const messages = data.messages || [];
document.getElementById('messageCount').textContent = messages.length;
if (messages.length === 0) {
document.getElementById('messageList').innerHTML =
'<p style="text-align: center; color: #999; padding: 40px;">暂无消息,开始聊天吧!</p>';
} else {
document.getElementById('messageList').innerHTML = messages.map(msg => `
<div class="message">
<div class="message-header">
<span class="message-emoji">${msg.sender_emoji || '🤖'}</span>
<span class="message-sender">${msg.sender_name}</span>
<span class="message-time">${new Date(msg.created_at).toLocaleString('zh-CN')}</span>
</div>
<div class="message-content">${msg.content}</div>
${msg.in_reply_to ? '<div style="font-size: 12px; color: #999; margin-top: 5px;">↩️ 回复消息 #' + msg.in_reply_to + '</div>' : ''}
</div>
`).join('');
lastMessageId = messages[messages.length - 1]?.id || 0;
}
} catch (e) {
showStatus(`❌ 加载消息失败:${e.message}`, 'error');
}
}
async function checkInbox() {
const meetingId = currentMeetingId || prompt('请输入会议 ID:');
const agentId = document.getElementById('agentId').value || 'flying_hero';
const agentName = document.getElementById('agentName').value || '飞行侠';
const agentEmoji = document.getElementById('agentEmoji').value || '🦸';
if (!meetingId) {
showStatus('❌ 请提供会议 ID', 'error');
return;
}
try {
const res = await fetch(
`${API_BASE}/meetings/${meetingId}/inbox/?agent_id=${agentId}&agent_name=${agentName}&agent_emoji=${encodeURIComponent(agentEmoji)}`
);
const data = await res.json();
if (res.ok) {
currentMeetingId = meetingId;
showStatus(`✅ 收到 ${data.unread_count} 条未读消息`, 'success');
if (data.participant) {
showStatus(`✅ Agent 已加入会议API Key: ${data.participant.api_key}`, 'info');
}
if (data.messages && data.messages.length > 0) {
document.getElementById('messageList').innerHTML = data.messages.map(msg => `
<div class="message" style="border-left: 3px solid #ffc107;">
<div class="message-header">
<span class="message-emoji">${msg.sender_emoji || '🤖'}</span>
<span class="message-sender">${msg.sender_name}</span>
<span class="message-time">${new Date(msg.created_at).toLocaleString('zh-CN')}</span>
${msg.requires_response ? '<span class="badge" style="background: #ffc107; margin-left: 8px;">待回复</span>' : ''}
</div>
<div class="message-content">${msg.content}</div>
<div style="font-size: 12px; color: #999; margin-top: 5px;">消息 ID: ${msg.id}</div>
</div>
`).join('');
}
} else {
showStatus(`${data.error || '查阅失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function agentReply() {
const meetingId = currentMeetingId || prompt('请输入会议 ID:');
const agentId = document.getElementById('agentId').value || 'flying_hero';
const agentName = document.getElementById('agentName').value || '飞行侠';
const agentEmoji = document.getElementById('agentEmoji').value || '🦸';
const content = document.getElementById('messageContent').value;
const inReplyTo = prompt('回复哪条消息?输入消息 ID:');
if (!meetingId || !content) {
showStatus('❌ 请提供会议 ID 和消息内容', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${meetingId}/agent_reply/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_id: agentId,
agent_name: agentName,
agent_emoji: agentEmoji,
content: content,
in_reply_to: inReplyTo ? parseInt(inReplyTo) : null
})
});
const data = await res.json();
if (res.ok) {
document.getElementById('messageContent').value = '';
showStatus('✅ Agent 回复成功!', 'success');
loadMessages();
} else {
showStatus(`${data.error || '回复失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function loadParticipants() {
if (!currentMeetingId) {
showStatus('❌ 请先创建或加入会议', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/participants/`);
const participants = await res.json();
document.getElementById('participantCount').textContent = participants.length;
if (participants.length === 0) {
document.getElementById('seatMap').innerHTML =
'<p style="text-align: center; color: #999; padding: 20px;">暂无参会者</p>';
} else {
// 圆桌座位布局
document.getElementById('seatMap').innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; padding: 20px;">
${participants.map(p => `
<div style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 50%;
width: 100px;
height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
">
<div style="font-size: 24px; margin-bottom: 5px;">${p.agent_emoji || '👤'}</div>
<div style="font-weight: 600;">${p.nickname || '参会者'}</div>
${p.is_host ? '<div style="font-size: 10px; opacity: 0.8;">👑 主持</div>' : ''}
</div>
`).join('')}
</div>
`;
}
} catch (e) {
showStatus(`❌ 加载座位失败:${e.message}`, 'error');
}
}
async function generateMinutes() {
if (!currentMeetingId) {
showStatus('❌ 请先创建或加入会议', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/minutes/?output=markdown`);
const data = await res.json();
if (res.ok && data.markdown) {
document.getElementById('minutesContent').textContent = data.markdown;
document.getElementById('minutesDisplay').style.display = 'block';
showStatus('✅ 会议纪要已生成!', 'success');
} else {
showStatus(`❌ 生成失败:${data.error || '未知错误'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function mentionAgent() {
const meetingId = currentMeetingId || prompt('请输入会议 ID:');
const targetAgentId = prompt('@哪个 Agent输入 agent_id:');
const content = document.getElementById('messageContent').value;
const senderAgentId = document.getElementById('agentId').value || 'human';
const senderName = document.getElementById('agentName').value || '用户';
const senderEmoji = document.getElementById('agentEmoji').value || '👤';
if (!meetingId || !targetAgentId || !content) {
showStatus('❌ 请填写完整信息', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${meetingId}/mention_agent/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_agent_id: targetAgentId,
content: content,
sender_agent_id: senderAgentId,
sender_name: senderName,
sender_emoji: senderEmoji
})
});
const data = await res.json();
if (res.ok) {
document.getElementById('messageContent').value = '';
showStatus(`✅ 已 @${targetAgentId}`, 'success');
loadMessages();
} else {
showStatus(`${data.error || '发送失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function startMeeting() {
if (!currentMeetingId) return;
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/start/`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${document.getElementById('token').value}` }
});
if (res.ok) {
showStatus('✅ 会议已开始!', 'success');
loadMeetingInfo();
} else {
const data = await res.json();
showStatus(`${data.error || '开始失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function endMeeting() {
if (!currentMeetingId) return;
if (!confirm('确定要结束会议吗?')) return;
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/end/`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${document.getElementById('token').value}` }
});
if (res.ok) {
showStatus('✅ 会议已结束!', 'success');
loadMeetingInfo();
} else {
const data = await res.json();
showStatus(`${data.error || '结束失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function exportMinutes() {
if (!currentMeetingId) return;
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/minutes/?output=markdown`);
const data = await res.json();
if (res.ok && data.markdown) {
// 创建下载链接
const blob = new Blob([data.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meeting-minutes-${currentMeetingId.slice(0, 8)}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showStatus('✅ 纪要已导出!', 'success');
} else {
showStatus(`❌ 导出失败`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function loadMeetingInfo() {
if (!currentMeetingId) return;
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/`, {
headers: { 'Authorization': `Bearer ${document.getElementById('token').value}` }
});
const meeting = await res.json();
document.getElementById('meetingTopic').textContent = meeting.topic;
document.getElementById('meetingId').textContent = meeting.id;
document.getElementById('inviteCode').textContent = meeting.invite_code;
document.getElementById('meetingStatus').textContent = meeting.status;
} catch (e) {
console.error('加载会议信息失败:', e);
}
}
// 自动登录(如果记得凭证)
document.getElementById('username').value = 'test';
document.getElementById('password').value = 'test123';
</script>
</body>
</html>