Compare commits
8 Commits
f74babe5f5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9562cc6d0a | |||
| 30d4602458 | |||
| 4a1de69f7b | |||
| aeccf647e6 | |||
| d5c6c3c786 | |||
| 49047b37e4 | |||
| 7bf62612b7 | |||
| e9e65f2e86 |
18
backend/meetings/migrations/0003_meeting_expires_at.py
Normal file
18
backend/meetings/migrations/0003_meeting_expires_at.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 01:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("meetings", "0002_meeting_host_agent_id_meeting_host_instance_id_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="meeting",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="过期时间"),
|
||||
),
|
||||
]
|
||||
@@ -21,6 +21,7 @@ class Meeting(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True, verbose_name="过期时间")
|
||||
|
||||
# 主持龙虾(负责生成会议纪要)
|
||||
host_agent_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持 Agent ID')
|
||||
|
||||
@@ -2,11 +2,9 @@ from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.utils import timezone
|
||||
from .models import Meeting, Participant, Message, MeetingMinutes
|
||||
from .serializers import (
|
||||
MeetingSerializer, ParticipantSerializer,
|
||||
MessageSerializer, InboxSerializer
|
||||
)
|
||||
from datetime import timedelta
|
||||
from .models import Meeting, Participant, Message
|
||||
from .serializers import MeetingSerializer, ParticipantSerializer, MessageSerializer
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.shortcuts import get_object_or_404
|
||||
import uuid
|
||||
@@ -21,7 +19,6 @@ class MeetingViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [] # 临时开放所有权限
|
||||
|
||||
def get_queryset(self):
|
||||
# 简单返回所有会议
|
||||
return Meeting.objects.all().order_by('-created_at')
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
@@ -29,20 +26,14 @@ class MeetingViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# 临时:使用第一个用户作为 host
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
# 使用第一个用户作为 host
|
||||
host = User.objects.first()
|
||||
|
||||
meeting = serializer.save(host=host)
|
||||
|
||||
# 指定主持龙虾(第一只,负责生成纪要)
|
||||
host_agent_id = request.data.get('host_agent_id')
|
||||
host_instance_id = request.data.get('host_instance_id')
|
||||
if host_agent_id:
|
||||
meeting.host_agent_id = host_agent_id
|
||||
meeting.host_instance_id = host_instance_id
|
||||
meeting.save()
|
||||
# 设置 1 小时过期时间
|
||||
meeting.expires_at = timezone.now() + timedelta(hours=1)
|
||||
meeting.save()
|
||||
|
||||
# 创建主持人参会记录
|
||||
Participant.objects.create(
|
||||
@@ -53,102 +44,35 @@ class MeetingViewSet(viewsets.ModelViewSet):
|
||||
is_host=True
|
||||
)
|
||||
|
||||
# 创建所有龙虾参会者(从 sessions 中获取)
|
||||
# 处理随行龙虾
|
||||
agent_ids = request.data.get('agent_ids', [])
|
||||
for agent_id in agent_ids:
|
||||
# 避免重复创建
|
||||
if Participant.objects.filter(meeting=meeting, agent_id=agent_id).exists():
|
||||
continue
|
||||
|
||||
# 从用户绑定的龙虾中获取正确信息
|
||||
agent_info = host.get_linked_agent(agent_id)
|
||||
Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_info['agent_name'] if agent_info else agent_id,
|
||||
agent_emoji=agent_info.get('agent_emoji', '🤖') if agent_info else '🤖',
|
||||
nickname=agent_info['agent_name'] if agent_info else agent_id,
|
||||
is_host=False
|
||||
)
|
||||
|
||||
# 如果没有龙虾,添加虚拟坐席
|
||||
if request.data.get('auto_add_virtual_agents', False):
|
||||
Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id='virtual_agent_1',
|
||||
agent_name='虚拟助手 1 号',
|
||||
agent_name='Agent',
|
||||
agent_emoji='🤖',
|
||||
nickname='虚拟助手 1 号',
|
||||
is_host=False
|
||||
)
|
||||
Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id='virtual_agent_2',
|
||||
agent_name='虚拟助手 2 号',
|
||||
agent_emoji='🦊',
|
||||
nickname='虚拟助手 2 号',
|
||||
nickname=f'Agent {agent_id}',
|
||||
is_host=False
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def start(self, request, pk=None):
|
||||
"""开始会议"""
|
||||
meeting = self.get_object()
|
||||
# 临时:不检查主持人权限(开发环境)
|
||||
# if meeting.host != request.user:
|
||||
# return Response(
|
||||
# {'error': '只有主持人可以开始会议'},
|
||||
# status=status.HTTP_403_FORBIDDEN
|
||||
# )
|
||||
|
||||
meeting.status = 'active'
|
||||
meeting.started_at = timezone.now()
|
||||
meeting.save()
|
||||
|
||||
return Response({'status': '会议已开始'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def end(self, request, pk=None):
|
||||
"""结束会议"""
|
||||
meeting = self.get_object()
|
||||
# 临时:不检查主持人权限(开发环境)
|
||||
# if meeting.host != request.user:
|
||||
# return Response(
|
||||
# {'error': '只有主持人可以结束会议'},
|
||||
# status=status.HTTP_403_FORBIDDEN
|
||||
# )
|
||||
|
||||
meeting.status = 'ended'
|
||||
meeting.ended_at = timezone.now()
|
||||
meeting.save()
|
||||
|
||||
# 触发通知主持龙虾生成纪要
|
||||
try:
|
||||
from .minutes_api import MeetingEndNotifyView
|
||||
from django.test import RequestFactory
|
||||
|
||||
factory = RequestFactory()
|
||||
notify_request = factory.post(f'/api/v1/meetings/{meeting.id}/end-notify/')
|
||||
response = MeetingEndNotifyView.as_view()(notify_request, pk=str(meeting.id))
|
||||
|
||||
if response.status_code == 200:
|
||||
pass # 通知成功
|
||||
except Exception as e:
|
||||
# 通知失败不影响会议结束
|
||||
pass
|
||||
|
||||
return Response({'status': '会议已结束'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def join(self, request, pk=None):
|
||||
"""加入会议"""
|
||||
"""加入会议(支持批量加入)"""
|
||||
meeting = self.get_object()
|
||||
|
||||
# 检查会议是否过期
|
||||
if meeting.expires_at and timezone.now() > meeting.expires_at:
|
||||
# 过期会议,清空坐席
|
||||
meeting.participants.all().delete()
|
||||
return Response(
|
||||
{'error': '会议已过期,坐席已清空'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if meeting.status == 'ended':
|
||||
return Response(
|
||||
{'error': '会议已结束'},
|
||||
@@ -162,122 +86,75 @@ class MeetingViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 检查是否已加入
|
||||
# 获取要加入的龙虾 ID 列表
|
||||
agent_ids = request.data.get('agent_ids', [])
|
||||
|
||||
# 获取当前用户
|
||||
user = User.objects.first() # 临时使用第一个用户
|
||||
|
||||
# 检查用户是否已加入
|
||||
existing = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
user=request.user,
|
||||
user=user,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return Response(ParticipantSerializer(existing).data)
|
||||
|
||||
# 创建参会记录
|
||||
participant = Participant.objects.create(
|
||||
meeting=meeting,
|
||||
user=request.user,
|
||||
agent_type='human',
|
||||
nickname=request.user.username
|
||||
)
|
||||
|
||||
return Response(ParticipantSerializer(participant).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def participants(self, request, pk=None):
|
||||
"""获取参会者列表"""
|
||||
meeting = self.get_object()
|
||||
participants = meeting.participants.filter(left_at__isnull=True)
|
||||
serializer = ParticipantSerializer(participants, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mention_agent(self, request, pk=None):
|
||||
"""@Agent 功能 - 发送消息给特定 Agent"""
|
||||
meeting = self.get_object()
|
||||
target_agent_id = request.data.get('target_agent_id')
|
||||
content = request.data.get('content')
|
||||
sender_agent_id = request.data.get('sender_agent_id')
|
||||
sender_name = request.data.get('sender_name', 'User')
|
||||
sender_emoji = request.data.get('sender_emoji', '👤')
|
||||
|
||||
if not target_agent_id or not content:
|
||||
return Response(
|
||||
{'error': '缺少 target_agent_id 或 content'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 找到目标 Agent
|
||||
target_participant = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
agent_id=target_agent_id,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not target_participant:
|
||||
return Response(
|
||||
{'error': f'未找到 Agent: {target_agent_id}'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# 获取或创建发送者
|
||||
sender_participant = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
agent_id=sender_agent_id,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not sender_participant and sender_agent_id:
|
||||
sender_participant = Participant.objects.create(
|
||||
if not existing:
|
||||
# 用户加入会议
|
||||
Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id=sender_agent_id,
|
||||
agent_name=sender_name,
|
||||
agent_emoji=sender_emoji,
|
||||
nickname=f"{sender_emoji} {sender_name}"
|
||||
user=user,
|
||||
agent_type='human',
|
||||
nickname=user.username if user else '用户',
|
||||
is_host=False
|
||||
)
|
||||
|
||||
if not sender_participant:
|
||||
# 人类发送的,用主持人
|
||||
sender_participant = Participant.objects.filter(
|
||||
# 随行龙虾加入会议
|
||||
joined_agents = []
|
||||
for agent_id in agent_ids:
|
||||
# 检查是否已存在
|
||||
exists = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
is_host=True
|
||||
agent_id=agent_id,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not exists:
|
||||
participant = Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id=agent_id,
|
||||
agent_name='Agent',
|
||||
agent_emoji='🤖',
|
||||
nickname=f'Agent {agent_id}',
|
||||
is_host=False
|
||||
)
|
||||
joined_agents.append(ParticipantSerializer(participant).data)
|
||||
|
||||
# 创建消息,标记为需要回复
|
||||
message = Message.objects.create(
|
||||
# 返回所有参会者
|
||||
participants = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
sender=sender_participant,
|
||||
content=f"@{target_participant.nickname} {content}",
|
||||
is_broadcast=False, # 只发给目标 Agent
|
||||
requires_response=True
|
||||
left_at__isnull=True
|
||||
)
|
||||
|
||||
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def messages(self, request, pk=None):
|
||||
"""获取消息(人类轮询)"""
|
||||
meeting = self.get_object()
|
||||
last_id = request.query_params.get('last_id', 0)
|
||||
|
||||
messages = meeting.messages.filter(id__gt=last_id).select_related('sender')
|
||||
serializer = MessageSerializer(messages, many=True)
|
||||
|
||||
return Response({'messages': serializer.data})
|
||||
return Response({
|
||||
'message': '加入成功',
|
||||
'participants': ParticipantSerializer(participants, many=True).data,
|
||||
'joined_agents': joined_agents
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def send_message(self, request, pk=None):
|
||||
"""发送消息"""
|
||||
meeting = self.get_object()
|
||||
|
||||
# 获取或创建参会者(临时:使用第一个参会者或创建)
|
||||
# 获取或创建参会者
|
||||
participant = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not participant:
|
||||
# 创建默认参会者
|
||||
host = meeting.host
|
||||
participant = Participant.objects.create(
|
||||
meeting=meeting,
|
||||
@@ -301,170 +178,59 @@ class MeetingViewSet(viewsets.ModelViewSet):
|
||||
requires_response=request.data.get('requires_response', False)
|
||||
)
|
||||
|
||||
# Webhook 推送通知
|
||||
try:
|
||||
from instances.webhook import push_message_to_instances
|
||||
from meetings.serializers import MessageSerializer
|
||||
|
||||
message_data = MessageSerializer(message).data
|
||||
target_agents = None
|
||||
|
||||
# 如果不是广播,只推送给特定 Agent
|
||||
if not message.is_broadcast:
|
||||
# 从@消息中提取目标 Agent
|
||||
if content.startswith('@'):
|
||||
# 简单解析 @Agent
|
||||
pass
|
||||
|
||||
push_message_to_instances(str(meeting.id), message_data, target_agents)
|
||||
except Exception as e:
|
||||
# Webhook 失败不影响消息发送
|
||||
pass
|
||||
# 如果是@消息,触发龙虾自动回复
|
||||
if content.startswith('@') and message.requires_response:
|
||||
self.auto_reply(message)
|
||||
|
||||
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def auto_reply(self, message):
|
||||
"""龙虾自动回复"""
|
||||
# 获取会议中所有龙虾
|
||||
agents = Participant.objects.filter(
|
||||
meeting=message.meeting,
|
||||
agent_type='openclaw',
|
||||
left_at__isnull=True
|
||||
)
|
||||
|
||||
# 每个龙虾都有 30% 概率回复
|
||||
import random
|
||||
for agent in agents:
|
||||
if random.random() < 0.3: # 30% 概率
|
||||
replies = [
|
||||
'收到!',
|
||||
'明白了~',
|
||||
'好的,我会处理',
|
||||
'👌',
|
||||
'嗯嗯',
|
||||
'在的!',
|
||||
]
|
||||
import random
|
||||
reply_content = random.choice(replies)
|
||||
|
||||
Message.objects.create(
|
||||
meeting=message.meeting,
|
||||
sender=agent,
|
||||
content=reply_content,
|
||||
is_broadcast=True,
|
||||
in_reply_to=message
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def inbox(self, request, pk=None):
|
||||
"""Agent 查阅信箱(自动加入会议如果还没加入)"""
|
||||
def messages(self, request, pk=None):
|
||||
"""获取消息"""
|
||||
meeting = self.get_object()
|
||||
agent_id = request.query_params.get('agent_id')
|
||||
agent_name = request.query_params.get('agent_name', 'Agent')
|
||||
agent_emoji = request.query_params.get('agent_emoji', '🤖')
|
||||
|
||||
if not agent_id:
|
||||
return Response(
|
||||
{'error': '缺少 agent_id 参数'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 找到或创建这个 Agent 的参会记录
|
||||
participant = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
agent_id=agent_id,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not participant:
|
||||
# Agent 首次访问,自动加入会议
|
||||
participant = Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
agent_emoji=agent_emoji,
|
||||
nickname=f"{agent_emoji} {agent_name}"
|
||||
)
|
||||
|
||||
# 获取发给这个 Agent 的消息(未读)
|
||||
messages = Message.objects.filter(
|
||||
meeting=meeting
|
||||
).exclude(
|
||||
read_by=participant
|
||||
)
|
||||
|
||||
# 标记为已读
|
||||
participant.read_messages.add(*messages)
|
||||
last_id = request.query_params.get('last_id', 0)
|
||||
|
||||
messages = meeting.messages.filter(id__gt=last_id).select_related('sender')
|
||||
serializer = MessageSerializer(messages, many=True)
|
||||
|
||||
return Response({
|
||||
'unread_count': messages.count(),
|
||||
'messages': serializer.data,
|
||||
'participant': ParticipantSerializer(participant).data
|
||||
})
|
||||
return Response({'messages': serializer.data})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def agent_reply(self, request, pk=None):
|
||||
"""Agent 回复消息"""
|
||||
@action(detail=True, methods=['get'])
|
||||
def participants(self, request, pk=None):
|
||||
"""获取参会者列表"""
|
||||
meeting = self.get_object()
|
||||
agent_id = request.data.get('agent_id')
|
||||
agent_name = request.data.get('agent_name', 'Agent')
|
||||
agent_emoji = request.data.get('agent_emoji', '🤖')
|
||||
content = request.data.get('content')
|
||||
in_reply_to = request.data.get('in_reply_to')
|
||||
|
||||
if not agent_id:
|
||||
return Response(
|
||||
{'error': '缺少 agent_id 参数'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not content:
|
||||
return Response(
|
||||
{'error': '消息内容不能为空'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 找到或创建 Agent 参会记录
|
||||
participant = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
agent_id=agent_id,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if not participant:
|
||||
participant = Participant.objects.create(
|
||||
meeting=meeting,
|
||||
agent_type='openclaw',
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
agent_emoji=agent_emoji,
|
||||
nickname=f"{agent_emoji} {agent_name}"
|
||||
)
|
||||
|
||||
# 创建回复消息
|
||||
message = Message.objects.create(
|
||||
meeting=meeting,
|
||||
sender=participant,
|
||||
content=content,
|
||||
is_broadcast=request.data.get('is_broadcast', True),
|
||||
requires_response=request.data.get('requires_response', False),
|
||||
in_reply_to_id=in_reply_to
|
||||
)
|
||||
|
||||
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='minutes')
|
||||
def minutes(self, request, pk=None):
|
||||
"""生成会议纪要"""
|
||||
meeting = self.get_object()
|
||||
|
||||
try:
|
||||
from .utils import generate_meeting_minutes, export_minutes_to_markdown
|
||||
|
||||
minutes = generate_meeting_minutes(str(meeting.id))
|
||||
output_format = request.query_params.get('output', 'json')
|
||||
|
||||
if output_format == 'markdown':
|
||||
md_content = export_minutes_to_markdown(minutes)
|
||||
return Response({'markdown': md_content})
|
||||
else:
|
||||
return Response(minutes)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'生成纪要失败:{str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class ParticipantViewSet(viewsets.ModelViewSet):
|
||||
"""参会者视图集"""
|
||||
queryset = Participant.objects.all()
|
||||
serializer_class = ParticipantSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def leave(self, request, pk=None):
|
||||
"""离开会议"""
|
||||
participant = self.get_object()
|
||||
|
||||
if participant.user != request.user:
|
||||
return Response(
|
||||
{'error': '无权操作'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
participant.left_at = timezone.now()
|
||||
participant.save()
|
||||
|
||||
return Response({'status': '已离开会议'})
|
||||
participants = meeting.participants.filter(left_at__isnull=True)
|
||||
serializer = ParticipantSerializer(participants, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
2
frontend/node_modules/.cache/.eslintcache
generated
vendored
2
frontend/node_modules/.cache/.eslintcache
generated
vendored
@@ -1 +1 @@
|
||||
[{"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js":"1","/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js":"2"},{"size":232,"mtime":1775265162529,"results":"3","hashOfConfig":"4"},{"size":19499,"mtime":1775294803463,"results":"5","hashOfConfig":"4"},{"filePath":"6","messages":"7","suppressedMessages":"8","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1sir4jg",{"filePath":"9","messages":"10","suppressedMessages":"11","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js",[],[],"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js",[],[]]
|
||||
[{"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js":"1","/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js":"2"},{"size":232,"mtime":1775265162529,"results":"3","hashOfConfig":"4"},{"size":12200,"mtime":1775353673604,"results":"5","hashOfConfig":"4"},{"filePath":"6","messages":"7","suppressedMessages":"8","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1sir4jg",{"filePath":"9","messages":"10","suppressedMessages":"11","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js",[],[],"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js",[],[]]
|
||||
BIN
frontend/node_modules/.cache/default-development/7.pack
generated
vendored
BIN
frontend/node_modules/.cache/default-development/7.pack
generated
vendored
Binary file not shown.
BIN
frontend/node_modules/.cache/default-development/8.pack
generated
vendored
BIN
frontend/node_modules/.cache/default-development/8.pack
generated
vendored
Binary file not shown.
BIN
frontend/node_modules/.cache/default-development/index.pack
generated
vendored
BIN
frontend/node_modules/.cache/default-development/index.pack
generated
vendored
Binary file not shown.
BIN
frontend/node_modules/.cache/default-development/index.pack.old
generated
vendored
BIN
frontend/node_modules/.cache/default-development/index.pack.old
generated
vendored
Binary file not shown.
@@ -14,21 +14,17 @@ axios.interceptors.request.use(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(() => {
|
||||
if (username) {
|
||||
scanAgents();
|
||||
}
|
||||
}, [username]);
|
||||
scanAgents();
|
||||
}, []);
|
||||
|
||||
const scanAgents = async () => {
|
||||
try {
|
||||
// 传递 username 参数,获取绑定的龙虾信息
|
||||
const res = await axios.get(`${API_BASE}/user/scan-local-agents/?username=${username}`);
|
||||
setAgents(res.data.agents || []);
|
||||
} catch (error) {
|
||||
@@ -39,24 +35,13 @@ function LoginPage() {
|
||||
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);
|
||||
const res = await axios.post(`${API_BASE}/auth/login/`, { username, password });
|
||||
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);
|
||||
localStorage.setItem('selectedAgents', JSON.stringify(selectedAgents));
|
||||
navigate('/meetings');
|
||||
} catch (error) {
|
||||
alert('登录失败:' + (error.response?.data?.detail || error.response?.data?.error || error.message));
|
||||
alert('登录失败:' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,71 +61,22 @@ function LoginPage() {
|
||||
<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 style={{margin: '15px 0', width: '100%'}}>
|
||||
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🦐 选择随行龙虾:</label>
|
||||
{agents.map(agent => (
|
||||
<label key={agent.agent_id} style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes(agent.agent_id)}
|
||||
onChange={() => toggleAgent(agent.agent_id)}
|
||||
style={{marginRight: '8px'}}
|
||||
/>
|
||||
{agent.agent_emoji || '🤖'} {agent.agent_name} ({agent.instance_name})
|
||||
</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>
|
||||
<button type="submit" style={styles.btn}>登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +87,6 @@ function LoginPage() {
|
||||
function MeetingList() {
|
||||
const [meetings, setMeetings] = useState([]);
|
||||
const [topic, setTopic] = useState('');
|
||||
const [autoAddAgents, setAutoAddAgents] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
@@ -170,15 +105,10 @@ function MeetingList() {
|
||||
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 selectedAgents = JSON.parse(localStorage.getItem('selectedAgents') || '[]');
|
||||
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 // 传递所有龙虾
|
||||
agent_ids: selectedAgents
|
||||
});
|
||||
navigate(`/meeting/${res.data.id}`);
|
||||
} catch (error) {
|
||||
@@ -198,19 +128,8 @@ function MeetingList() {
|
||||
<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 => (
|
||||
@@ -234,14 +153,11 @@ function MeetingRoom() {
|
||||
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]);
|
||||
@@ -250,9 +166,27 @@ function MeetingRoom() {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
|
||||
setMeeting(res.data);
|
||||
// 获取会议信息后,自动加入会议
|
||||
if (res.data.invite_code) {
|
||||
joinMeeting(res.data.invite_code);
|
||||
}
|
||||
} catch (error) { console.error(error); }
|
||||
};
|
||||
|
||||
const joinMeeting = async (inviteCode) => {
|
||||
try {
|
||||
const selectedAgents = JSON.parse(localStorage.getItem('selectedAgents') || '[]');
|
||||
await axios.post(`${API_BASE}/meetings/${id}/join/`, {
|
||||
invite_code: inviteCode,
|
||||
agent_ids: selectedAgents
|
||||
});
|
||||
// 加入后刷新参会者列表
|
||||
fetchParticipants();
|
||||
} catch (error) {
|
||||
console.log('加入会议:', error?.response?.data?.error || '已加入');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
|
||||
@@ -271,7 +205,12 @@ function MeetingRoom() {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, { content });
|
||||
// 检查是否是@消息
|
||||
const requiresResponse = content.startsWith('@');
|
||||
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, {
|
||||
content,
|
||||
requires_response: requiresResponse
|
||||
});
|
||||
setContent('');
|
||||
fetchMessages();
|
||||
} catch (error) {
|
||||
@@ -279,80 +218,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 (
|
||||
<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>
|
||||
@@ -360,16 +232,8 @@ function MeetingRoom() {
|
||||
{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 ? '点击 @ 此人' : ''}
|
||||
style={styles.seat}
|
||||
title={p.nickname}
|
||||
>
|
||||
<div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
|
||||
<div style={styles.seatName}>{p.nickname}</div>
|
||||
@@ -395,9 +259,8 @@ function MeetingRoom() {
|
||||
))}
|
||||
</div>
|
||||
<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="button" onClick={mentionAgent} style={styles.btnPink}>📍 @Agent</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,24 +287,17 @@ const styles = {
|
||||
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)' },
|
||||
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' },
|
||||
seatEmoji: { fontSize: '28px', marginBottom: '5px' },
|
||||
seatName: { fontSize: '12px', fontWeight: '600' },
|
||||
hostBadge: { fontSize: '10px', opacity: '0.8' },
|
||||
|
||||
66
start-dev.sh
Executable file
66
start-dev.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# 龙虾议事厅 - 开发环境快速启动脚本
|
||||
|
||||
echo "🏛️ 启动龙虾议事厅开发环境..."
|
||||
|
||||
# 检查并停止旧服务
|
||||
echo "🧹 清理旧服务..."
|
||||
|
||||
# 方法 1: 使用 lsof 查找并杀死占用端口的进程
|
||||
for port in 8000 3000; do
|
||||
pid=$(lsof -t -i:$port 2>/dev/null)
|
||||
if [ -n "$pid" ]; then
|
||||
echo " 杀死占用端口 $port 的进程 (PID: $pid)..."
|
||||
kill -9 $pid 2>/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# 方法 2: 使用 pkill 清理
|
||||
pkill -9 -f "manage.py runserver" 2>/dev/null
|
||||
pkill -9 -f "npm start" 2>/dev/null
|
||||
pkill -9 -f "node.*react-scripts" 2>/dev/null
|
||||
|
||||
sleep 2
|
||||
|
||||
# 启动后端
|
||||
echo "📦 启动后端..."
|
||||
cd backend
|
||||
nohup python3 manage.py runserver 0.0.0.0:8000 > /tmp/meeting-backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
sleep 2
|
||||
if ps -p $BACKEND_PID > /dev/null; then
|
||||
echo "✅ 后端已启动 (PID: $BACKEND_PID)"
|
||||
else
|
||||
echo "❌ 后端启动失败,请检查日志:/tmp/meeting-backend.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 启动前端
|
||||
echo "📦 启动前端..."
|
||||
cd ../frontend
|
||||
nohup npm start > /tmp/meeting-frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
sleep 3
|
||||
if ps -p $FRONTEND_PID > /dev/null; then
|
||||
echo "✅ 前端已启动 (PID: $FRONTEND_PID)"
|
||||
else
|
||||
echo "❌ 前端启动失败,请检查日志:/tmp/meeting-frontend.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 开发环境启动完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "📌 访问地址:"
|
||||
echo " 前端:http://localhost:3000/"
|
||||
echo " 后端 API: http://localhost:8000/api/v1/"
|
||||
echo ""
|
||||
echo "📌 日志文件:"
|
||||
echo " 后端:/tmp/meeting-backend.log"
|
||||
echo " 前端:/tmp/meeting-frontend.log"
|
||||
echo ""
|
||||
echo "📌 停止服务:"
|
||||
echo " kill $BACKEND_PID $FRONTEND_PID"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user