2026-04-04 11:19:01 +08:00
|
|
|
|
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');
|
2026-04-04 11:49:54 +08:00
|
|
|
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
2026-04-04 11:19:01 +08:00
|
|
|
|
return config;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ============ 登录页面 ============
|
|
|
|
|
|
function LoginPage() {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const [username, setUsername] = useState('test');
|
|
|
|
|
|
const [password, setPassword] = useState('test123');
|
2026-04-04 12:57:24 +08:00
|
|
|
|
const [loginMode, setLoginMode] = useState('human_only');
|
|
|
|
|
|
const [agents, setAgents] = useState([]);
|
|
|
|
|
|
const [selectedAgent, setSelectedAgent] = useState('');
|
2026-04-04 11:19:01 +08:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
|
2026-04-04 12:57:24 +08:00
|
|
|
|
// 扫描本机龙虾
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
scanAgents();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const scanAgents = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await axios.get(`${API_BASE}/user/scan-local-agents/`);
|
|
|
|
|
|
setAgents(res.data.agents || []);
|
|
|
|
|
|
if (res.data.agents?.length > 0) {
|
|
|
|
|
|
setSelectedAgent(res.data.agents[0].agent_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('扫描龙虾失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-04 11:19:01 +08:00
|
|
|
|
const handleLogin = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
try {
|
2026-04-04 12:57:24 +08:00
|
|
|
|
const payload = {
|
|
|
|
|
|
username,
|
|
|
|
|
|
password,
|
|
|
|
|
|
login_mode: loginMode
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loginMode !== 'human_only' && selectedAgent) {
|
|
|
|
|
|
payload.selected_agent_id = selectedAgent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const res = await axios.post(`${API_BASE}/auth/login/`, payload);
|
2026-04-04 11:49:54 +08:00
|
|
|
|
localStorage.setItem('token', res.data.token);
|
|
|
|
|
|
localStorage.setItem('user', JSON.stringify(res.data.user));
|
2026-04-04 12:57:24 +08:00
|
|
|
|
localStorage.setItem('sessions', JSON.stringify(res.data.sessions));
|
|
|
|
|
|
localStorage.setItem('login_mode', res.data.login_mode);
|
2026-04-04 11:19:01 +08:00
|
|
|
|
navigate('/meetings');
|
|
|
|
|
|
} catch (error) {
|
2026-04-04 12:57:24 +08:00
|
|
|
|
alert('登录失败:' + (error.response?.data?.detail || error.response?.data?.error || error.message));
|
2026-04-04 11:19:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<div style={styles.center}>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
<div style={styles.card}>
|
|
|
|
|
|
<h1 style={styles.title}>🏛️ 龙虾议事厅</h1>
|
2026-04-04 12:57:24 +08:00
|
|
|
|
<form onSubmit={handleLogin} style={{...styles.form, flexDirection: 'column'}}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<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 />
|
2026-04-04 12:57:24 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 身份模式选择 */}
|
|
|
|
|
|
<div style={{margin: '15px 0'}}>
|
|
|
|
|
|
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>登录身份:</label>
|
|
|
|
|
|
<label style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
name="loginMode"
|
|
|
|
|
|
value="human_only"
|
|
|
|
|
|
checked={loginMode === 'human_only'}
|
|
|
|
|
|
onChange={e => setLoginMode(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{' '}👤 人类身份(纯人类参会)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
name="loginMode"
|
|
|
|
|
|
value="agent_only"
|
|
|
|
|
|
checked={loginMode === 'agent_only'}
|
|
|
|
|
|
onChange={e => setLoginMode(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{' '}🦞 龙虾身份(Agent 参会)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
name="loginMode"
|
|
|
|
|
|
value="both"
|
|
|
|
|
|
checked={loginMode === 'both'}
|
|
|
|
|
|
onChange={e => setLoginMode(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{' '}👤+ 双重身份(人类 + 龙虾)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 龙虾选择 */}
|
|
|
|
|
|
{loginMode !== 'human_only' && (
|
|
|
|
|
|
<div style={{margin: '15px 0'}}>
|
|
|
|
|
|
<label style={{display: 'block', marginBottom: '8px', fontWeight: '600'}}>选择龙虾:</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedAgent}
|
|
|
|
|
|
onChange={e => setSelectedAgent(e.target.value)}
|
|
|
|
|
|
style={styles.input}
|
|
|
|
|
|
>
|
|
|
|
|
|
{agents.length === 0 ? (
|
|
|
|
|
|
<option value="">未找到龙虾</option>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
agents.map(a => (
|
|
|
|
|
|
<option key={a.agent_id} value={a.agent_id}>
|
|
|
|
|
|
{a.agent_id} ({a.instance_name})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<button type="submit" style={styles.btn}>登录</button>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
// ============ 会议列表 ============
|
2026-04-04 11:19:01 +08:00
|
|
|
|
function MeetingList() {
|
|
|
|
|
|
const [meetings, setMeetings] = useState([]);
|
|
|
|
|
|
const [topic, setTopic] = useState('');
|
2026-04-04 13:04:26 +08:00
|
|
|
|
const [autoAddAgents, setAutoAddAgents] = useState(true);
|
2026-04-04 11:19:01 +08:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
if (!token) { navigate('/login'); return; }
|
2026-04-04 11:19:01 +08:00
|
|
|
|
fetchMeetings();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMeetings = async () => {
|
|
|
|
|
|
try {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const res = await axios.get(`${API_BASE}/meetings/`);
|
|
|
|
|
|
setMeetings(res.data);
|
|
|
|
|
|
} catch (error) { console.error(error); }
|
2026-04-04 11:19:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createMeeting = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
try {
|
2026-04-04 13:04:26 +08:00
|
|
|
|
const res = await axios.post(`${API_BASE}/meetings/`, {
|
|
|
|
|
|
topic,
|
|
|
|
|
|
auto_add_virtual_agents: autoAddAgents
|
|
|
|
|
|
});
|
2026-04-04 11:49:54 +08:00
|
|
|
|
navigate(`/meeting/${res.data.id}`);
|
2026-04-04 11:19:01 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert('创建失败:' + (error.response?.data?.detail || error.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const logout = () => { localStorage.removeItem('token'); navigate('/login'); };
|
2026-04-04 11:19:01 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={styles.container}>
|
|
|
|
|
|
<div style={styles.header}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<h1>📋 我的会议室</h1>
|
|
|
|
|
|
<button onClick={logout} style={styles.smallBtn}>退出</button>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={styles.card}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<h2>创建会议</h2>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
<form onSubmit={createMeeting} style={styles.form}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<input type="text" placeholder="会议主题" value={topic} onChange={e => setTopic(e.target.value)} style={styles.input} required />
|
2026-04-04 13:04:26 +08:00
|
|
|
|
<label style={{display: 'flex', alignItems: 'center', gap: '5px', whiteSpace: 'nowrap'}}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={autoAddAgents}
|
|
|
|
|
|
onChange={e => setAutoAddAgents(e.target.checked)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
添加虚拟坐席
|
|
|
|
|
|
</label>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<button type="submit" style={styles.btn}>创建</button>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</form>
|
2026-04-04 13:04:26 +08:00
|
|
|
|
<p style={{fontSize: '12px', color: '#666', marginTop: '10px'}}>
|
|
|
|
|
|
💡 勾选"添加虚拟坐席"会自动创建 2 个虚拟龙虾参会者,方便测试 @ 功能
|
|
|
|
|
|
</p>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</div>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<div style={styles.list}>
|
|
|
|
|
|
{meetings.map(m => (
|
|
|
|
|
|
<div key={m.id} style={styles.item}>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
<div>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<h3>{m.topic}</h3>
|
|
|
|
|
|
<p>状态:{m.status} | 邀请码:{m.invite_code}</p>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</div>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<button onClick={() => navigate(`/meeting/${m.id}`)} style={styles.smallBtn}>进入</button>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
// ============ 会议室 ============
|
2026-04-04 11:19:01 +08:00
|
|
|
|
function MeetingRoom() {
|
|
|
|
|
|
const { id } = useParams();
|
|
|
|
|
|
const [messages, setMessages] = useState([]);
|
|
|
|
|
|
const [content, setContent] = useState('');
|
|
|
|
|
|
const [participants, setParticipants] = useState([]);
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const [meeting, setMeeting] = useState(null);
|
2026-04-04 11:53:20 +08:00
|
|
|
|
const [hoveredSeat, setHoveredSeat] = useState(null);
|
2026-04-04 11:19:01 +08:00
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!token) return;
|
2026-04-04 11:49:54 +08:00
|
|
|
|
fetchMeeting();
|
2026-04-04 11:19:01 +08:00
|
|
|
|
fetchParticipants();
|
|
|
|
|
|
fetchMessages();
|
|
|
|
|
|
const interval = setInterval(fetchMessages, 1000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
2026-04-04 11:49:54 +08:00
|
|
|
|
}, [id]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMeeting = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
|
|
|
|
|
|
setMeeting(res.data);
|
|
|
|
|
|
} catch (error) { console.error(error); }
|
|
|
|
|
|
};
|
2026-04-04 11:19:01 +08:00
|
|
|
|
|
|
|
|
|
|
const fetchParticipants = async () => {
|
|
|
|
|
|
try {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
|
|
|
|
|
|
setParticipants(res.data);
|
|
|
|
|
|
} catch (error) { console.error(error); }
|
2026-04-04 11:19:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMessages = async () => {
|
|
|
|
|
|
try {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
const res = await axios.get(`${API_BASE}/meetings/${id}/messages/?last_id=0`);
|
|
|
|
|
|
setMessages(res.data.messages || []);
|
|
|
|
|
|
} catch (error) { console.error(error); }
|
2026-04-04 11:19:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sendMessage = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
2026-04-04 11:49:54 +08:00
|
|
|
|
if (!content.trim()) return;
|
2026-04-04 11:19:01 +08:00
|
|
|
|
try {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, { content });
|
2026-04-04 11:19:01 +08:00
|
|
|
|
setContent('');
|
|
|
|
|
|
fetchMessages();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert('发送失败:' + (error.response?.data?.detail || error.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-04 11:53:20 +08:00
|
|
|
|
const mentionAgent = async (targetAgentId, agentName) => {
|
|
|
|
|
|
const target = targetAgentId || prompt('@哪个 Agent?输入 agent_id:');
|
|
|
|
|
|
if (!target || !content.trim()) return;
|
|
|
|
|
|
const name = agentName || target;
|
2026-04-04 11:49:54 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await axios.post(`${API_BASE}/meetings/${id}/mention_agent/`, {
|
2026-04-04 11:53:20 +08:00
|
|
|
|
target_agent_id: target, content,
|
2026-04-04 11:49:54 +08:00
|
|
|
|
sender_name: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')).username : 'User'
|
|
|
|
|
|
});
|
|
|
|
|
|
setContent('');
|
|
|
|
|
|
fetchMessages();
|
2026-04-04 11:53:20 +08:00
|
|
|
|
alert(`✅ 已 @${name}`);
|
2026-04-04 11:49:54 +08:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-04 11:19:01 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div style={styles.container}>
|
|
|
|
|
|
<div style={styles.header}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<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}>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
{participants.map(p => (
|
2026-04-04 11:53:20 +08:00
|
|
|
|
<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 ? '点击 @ 此人' : ''}
|
|
|
|
|
|
>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
|
|
|
|
|
|
<div style={styles.seatName}>{p.nickname}</div>
|
|
|
|
|
|
{p.is_host && <div style={styles.hostBadge}>👑</div>}
|
|
|
|
|
|
</div>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
{/* 聊天 */}
|
|
|
|
|
|
<div style={styles.card}>
|
|
|
|
|
|
<h2>💬 聊天 <span style={styles.badge}>{messages.length}</span></h2>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
<div style={styles.messages}>
|
|
|
|
|
|
{messages.map(msg => (
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<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>}
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form onSubmit={sendMessage} style={styles.form}>
|
2026-04-04 11:49:54 +08:00
|
|
|
|
<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>
|
2026-04-04 11:19:01 +08:00
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 11:49:54 +08:00
|
|
|
|
// ============ App ============
|
2026-04-04 11:19:01 +08:00
|
|
|
|
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 = {
|
2026-04-04 11:49:54 +08:00
|
|
|
|
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' },
|
2026-04-04 11:53:20 +08:00
|
|
|
|
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)' },
|
2026-04-04 11:49:54 +08:00
|
|
|
|
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' }
|
2026-04-04 11:19:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default App;
|