🎯 重构登录逻辑:以人为核心的三种出战模式

核心变更:
- 单枪匹马 (solo) - 人类单独出战
- 组队团战 (team) - 人类 +N 龙虾
- 独当一面 (agent_only) - 龙虾单独出征

后端:
- users/views.py: 支持多选 agent_ids
- 新增 mode_names 映射
- 错误提示优化

前端:
- 新模式选择 UI(带图标和说明)
- 多选龙虾复选框
- 实时显示已选龙虾数量
- 选中模式高亮显示

测试:
- test_new_login.py: 完整测试三种模式
- 绑定第二只龙虾(龙虾监控 🦞)

结果:
 单枪匹马 - 1 个人类座位
 组队团战 - 1+N 个座位(人类 + 龙虾)
 独当一面 - N 个龙虾座位
This commit is contained in:
2026-04-04 16:41:13 +08:00
parent 97e4a6fecb
commit 65000664ef
3 changed files with 191 additions and 66 deletions

92
backend/test_new_login.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
测试新登录逻辑(单枪匹马/组队团战/独当一面)
"""
import requests
API_BASE = 'http://localhost:8000/api/v1'
def test_new_login():
print("="*60)
print("🎯 测试新登录逻辑")
print("="*60)
# 1. 单枪匹马
print("\n🥷 测试 1: 单枪匹马(仅人类)")
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123',
'mode': 'solo'
})
if res.status_code == 200:
data = res.json()
print(f"✅ 登录成功")
print(f" 模式:{data['mode_name']}")
print(f" 会话数:{len(data['sessions'])}")
for s in data['sessions']:
print(f" - {s['session_type']}: {s['nickname']} ({s['emoji']})")
else:
print(f"❌ 登录失败:{res.json()}")
# 2. 组队团战
print("\n🛡️ 测试 2: 组队团战(人类 +2 龙虾)")
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123',
'mode': 'team',
'agent_ids': ['flying_hero', 'lobster_monitor']
})
if res.status_code == 200:
data = res.json()
print(f"✅ 登录成功")
print(f" 模式:{data['mode_name']}")
print(f" 会话数:{len(data['sessions'])}")
for s in data['sessions']:
emoji = s.get('emoji', '🤖')
print(f" - {s['session_type']}: {s['nickname']} ({emoji})")
else:
print(f"❌ 登录失败:{res.json()}")
# 3. 独当一面
print("\n⚔️ 测试 3: 独当一面(仅龙虾)")
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123',
'mode': 'agent_only',
'agent_ids': ['flying_hero']
})
if res.status_code == 200:
data = res.json()
print(f"✅ 登录成功")
print(f" 模式:{data['mode_name']}")
print(f" 会话数:{len(data['sessions'])}")
for s in data['sessions']:
emoji = s.get('emoji', '🤖')
print(f" - {s['session_type']}: {s['nickname']} ({emoji})")
else:
print(f"❌ 登录失败:{res.json()}")
# 4. 错误测试 - 组队但没选龙虾
print("\n❌ 测试 4: 组队但没选龙虾(应该失败)")
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123',
'mode': 'team',
'agent_ids': []
})
if res.status_code == 400:
print(f"✅ 正确失败:{res.json()['error']}")
else:
print(f"❌ 应该失败但成功了:{res.json()}")
print("\n" + "="*60)
print("✅ 新登录逻辑测试完成!")
print("="*60)
print("\n📊 三种模式:")
print("1. 🥷 单枪匹马 - 人类单独出战")
print("2. 🛡️ 组队团战 - 人类 +N 龙虾")
print("3. ⚔️ 独当一面 - 龙虾单独出征")
if __name__ == '__main__':
test_new_login()

View File

@@ -8,11 +8,17 @@ User = get_user_model()
class LoginSerializer(serializers.Serializer): class LoginSerializer(serializers.Serializer):
username = serializers.CharField() username = serializers.CharField()
password = serializers.CharField() password = serializers.CharField()
login_mode = serializers.ChoiceField( mode = serializers.ChoiceField(
choices=['human_only', 'agent_only', 'both'], choices=['solo', 'team', 'agent_only'],
default='human_only' default='solo',
help_text='solo=单枪匹马team=组队团战agent_only=独当一面'
)
agent_ids = serializers.ListField(
child=serializers.CharField(),
required=False,
default=list,
help_text='选择的龙虾 ID 列表team 或 agent_only 模式)'
) )
selected_agent_id = serializers.CharField(required=False, allow_blank=True)
class LoginView(views.APIView): class LoginView(views.APIView):
@@ -23,8 +29,8 @@ class LoginView(views.APIView):
username = serializer.validated_data['username'] username = serializer.validated_data['username']
password = serializer.validated_data['password'] password = serializer.validated_data['password']
login_mode = serializer.validated_data.get('login_mode', 'human_only') mode = serializer.validated_data.get('mode', 'solo')
selected_agent_id = serializer.validated_data.get('selected_agent_id') agent_ids = serializer.validated_data.get('agent_ids', [])
user = authenticate(username=username, password=password) user = authenticate(username=username, password=password)
if not user: if not user:
@@ -40,8 +46,8 @@ class LoginView(views.APIView):
# 构建会话信息 # 构建会话信息
sessions = [] sessions = []
if login_mode in ['human_only', 'both']: if mode in ['solo', 'team']:
# 人类身份 # 人类身份(单枪匹马 或 组队团战)
sessions.append({ sessions.append({
'session_type': 'human', 'session_type': 'human',
'nickname': user.username, 'nickname': user.username,
@@ -49,10 +55,17 @@ class LoginView(views.APIView):
'user_id': user.id 'user_id': user.id
}) })
if login_mode in ['agent_only', 'both']: if mode in ['team', 'agent_only']:
# 龙虾身份 # 龙虾身份(组队团战 或 独当一面)
if selected_agent_id: if not agent_ids:
agent = user.get_linked_agent(selected_agent_id) return Response(
{'error': '组队或独当一面模式需要选择至少一只龙虾'},
status=status.HTTP_400_BAD_REQUEST
)
# 添加所有选择的龙虾
for agent_id in agent_ids:
agent = user.get_linked_agent(agent_id)
if agent: if agent:
sessions.append({ sessions.append({
'session_type': 'agent', 'session_type': 'agent',
@@ -64,10 +77,17 @@ class LoginView(views.APIView):
}) })
else: else:
return Response( return Response(
{'error': f'未找到绑定的龙虾:{selected_agent_id}'}, {'error': f'未找到绑定的龙虾:{agent_id}'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# 模式名称映射
mode_names = {
'solo': '单枪匹马',
'team': '组队团战',
'agent_only': '独当一面'
}
return Response({ return Response({
'token': token, 'token': token,
'user': { 'user': {
@@ -77,7 +97,8 @@ class LoginView(views.APIView):
'linked_agents': user.linked_agents 'linked_agents': user.linked_agents
}, },
'sessions': sessions, 'sessions': sessions,
'login_mode': login_mode 'mode': mode,
'mode_name': mode_names.get(mode, mode)
}) })

View File

@@ -14,9 +14,9 @@ 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 [loginMode, setLoginMode] = useState('human_only'); const [mode, setMode] = useState('solo');
const [agents, setAgents] = useState([]); const [agents, setAgents] = useState([]);
const [selectedAgent, setSelectedAgent] = useState(''); const [selectedAgents, setSelectedAgents] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
// 扫描本机龙虾 // 扫描本机龙虾
@@ -28,9 +28,6 @@ function LoginPage() {
try { try {
const res = await axios.get(`${API_BASE}/user/scan-local-agents/`); const res = await axios.get(`${API_BASE}/user/scan-local-agents/`);
setAgents(res.data.agents || []); setAgents(res.data.agents || []);
if (res.data.agents?.length > 0) {
setSelectedAgent(res.data.agents[0].agent_id);
}
} catch (error) { } catch (error) {
console.error('扫描龙虾失败:', error); console.error('扫描龙虾失败:', error);
} }
@@ -42,24 +39,32 @@ function LoginPage() {
const payload = { const payload = {
username, username,
password, password,
login_mode: loginMode mode
}; };
if (loginMode !== 'human_only' && selectedAgent) { if (mode !== 'solo' && selectedAgents.length > 0) {
payload.selected_agent_id = selectedAgent; payload.agent_ids = selectedAgents;
} }
const res = await axios.post(`${API_BASE}/auth/login/`, payload); 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('sessions', JSON.stringify(res.data.sessions));
localStorage.setItem('login_mode', res.data.login_mode); 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.response?.data?.error || 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}>
@@ -68,64 +73,71 @@ function LoginPage() {
<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 />
{/* 身份模式选择 */} {/* 出战模式选择 */}
<div style={{margin: '15px 0'}}> <div style={{margin: '15px 0'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>登录身份</label> <label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🎯 出战模式</label>
<label style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}> <label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'solo' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input <input
type="radio" type="radio"
name="loginMode" name="mode"
value="human_only" value="solo"
checked={loginMode === 'human_only'} checked={mode === 'solo'}
onChange={e => setLoginMode(e.target.value)} onChange={e => setMode(e.target.value)}
/> />
{' '}👤 人类身份纯人类参会 {' '}🥷 <strong>单枪匹马</strong>
</label> </label>
<label style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}> <label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'team' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input <input
type="radio" type="radio"
name="loginMode" 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" value="agent_only"
checked={loginMode === 'agent_only'} checked={mode === 'agent_only'}
onChange={e => setLoginMode(e.target.value)} onChange={e => setMode(e.target.value)}
/> />
{' '}🦞 龙虾身份Agent 参会 {' '} <strong>独当一面</strong>
</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> </label>
</div> </div>
{/* 龙虾选择 */} {/* 龙虾选择(组队或独当一面) */}
{loginMode !== 'human_only' && ( {mode !== 'solo' && (
<div style={{margin: '15px 0'}}> <div style={{margin: '15px 0', padding: '15px', background: '#f9f9f9', borderRadius: '8px'}}>
<label style={{display: 'block', marginBottom: '8px', fontWeight: '600'}}>选择龙虾</label> <label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🦐 选择龙虾队友</label>
<select
value={selectedAgent}
onChange={e => setSelectedAgent(e.target.value)}
style={styles.input}
>
{agents.length === 0 ? ( {agents.length === 0 ? (
<option value="">未找到龙虾</option> <p style={{color: '#999', fontSize: '14px'}}>未找到可用龙虾</p>
) : ( ) : (
agents.map(a => ( agents.map(a => (
<option key={a.agent_id} value={a.agent_id}> <label key={a.agent_id} style={{display: 'flex', alignItems: 'center', marginBottom: '8px', cursor: 'pointer'}}>
{a.agent_id} ({a.instance_name}) <input
</option> 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>
)) ))
)} )}
</select> {selectedAgents.length > 0 && (
<p style={{marginTop: '10px', color: '#2196f3', fontWeight: '600'}}>
已选 {selectedAgents.length} 只龙虾队友 🦸
</p>
)}
</div> </div>
)} )}
<button type="submit" style={styles.btn}>登录</button> <button type="submit" style={styles.btn}>🚀 登录出征</button>
</form> </form>
</div> </div>
</div> </div>