Merge remote-tracking branch 'origin/alan-dev' into codex/dev

codex/dev
mula.liu 2026-04-03 15:09:22 +08:00
commit cec4f98d42
39 changed files with 3010 additions and 937 deletions

View File

@ -82,8 +82,8 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
### 环境要求
- Node.js 16+
- Python 3.9+
- Node.js 22.12+
- Python 3.12+
- MySQL 5.7+
- Redis 5.0+
- Docker (可选)
@ -95,7 +95,7 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
```bash
cd backend
pip install -r requirements.txt
python main.py
python app/main.py
```
默认运行在 `http://localhost:8000`
@ -110,6 +110,17 @@ npm run dev
默认运行在 `http://localhost:5173`
#### 使用 Conda 一键启动前后端
项目根目录提供了 Conda 启动脚本,会分别创建并使用独立环境启动前后端:
- 后端环境: `imetting_backend` (Python 3.12)
- 前端环境: `imetting_frontend` (Node.js 22)
```bash
./start-conda.sh
```
### 配置说明
详细的配置文档请参考:

View File

@ -9,6 +9,7 @@ from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.llm_service import LLMService
from app.services.system_config_service import SystemConfigService
router = APIRouter()
llm_service = LLMService()
@ -800,6 +801,18 @@ async def test_audio_model_config(request: AudioModelTestRequest, current_user=D
return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}")
@router.get("/system-config/public")
async def get_public_system_config():
try:
return create_api_response(
code="200",
message="获取公开配置成功",
data=SystemConfigService.get_branding_config()
)
except Exception as e:
return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}")
@router.get("/admin/system-config")
async def get_system_config_compat(current_user=Depends(get_current_admin_user)):
"""兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。"""

View File

@ -53,6 +53,19 @@ def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] =
tags_data = cursor.fetchall()
return [Tag(**tag) for tag in tags_data]
def _sync_attendees(cursor, meeting_id: int, attendee_ids: Optional[List[int]]) -> None:
attendee_id_list = []
if attendee_ids:
attendee_id_list = list(dict.fromkeys(int(user_id) for user_id in attendee_ids if user_id is not None))
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
if attendee_id_list:
cursor.executemany(
'INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)',
[(meeting_id, user_id) for user_id in attendee_id_list]
)
def _get_meeting_overall_status(meeting_id: int) -> dict:
"""
获取会议的整体进度状态包含转译和LLM两个阶段
@ -248,7 +261,7 @@ def get_meetings(
meeting_list.append(Meeting(
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'],
attendees=attendees, creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list,
attendees=attendees, attendee_ids=[row['user_id'] for row in attendees_data], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list,
access_password=meeting.get('access_password'),
overall_status=progress_info.get('overall_status'),
overall_progress=progress_info.get('overall_progress'),
@ -319,7 +332,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
cursor = connection.cursor(dictionary=True)
query = '''
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
m.user_id as creator_id, u.caption as creator_username,
m.user_id as creator_id, u.caption as creator_username, m.prompt_id,
af.file_path as audio_file_path, af.duration as audio_duration,
p.name as prompt_name, m.access_password
FROM meetings m
@ -341,7 +354,9 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
meeting_data = Meeting(
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
attendee_ids=[row['user_id'] for row in attendees_data],
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
prompt_id=meeting.get('prompt_id'),
prompt_name=meeting.get('prompt_name'),
access_password=meeting.get('access_password')
)
@ -355,6 +370,12 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data)
except Exception as e:
print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}")
try:
llm_status_data = async_meeting_service.get_meeting_llm_status(meeting_id)
if llm_status_data:
meeting_data.llm_status = TranscriptionTaskStatus(**llm_status_data)
except Exception as e:
print(f"Warning: Failed to get llm status for meeting {meeting_id}: {e}")
return create_api_response(code="200", message="获取会议详情成功", data=meeting_data)
@router.get("/meetings/{meeting_id}/transcript")
@ -385,17 +406,24 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D
# 使用 _process_tags 来处理标签创建
if meeting_request.tags:
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)'
cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat()))
meeting_query = '''
INSERT INTO meetings (user_id, title, meeting_time, summary, tags, prompt_id, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
'''
cursor.execute(
meeting_query,
(
current_user['user_id'],
meeting_request.title,
meeting_request.meeting_time,
None,
meeting_request.tags,
meeting_request.prompt_id or 0,
datetime.now().isoformat(),
),
)
meeting_id = cursor.lastrowid
# 根据 caption 查找用户ID并插入参会人
if meeting_request.attendees:
captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()]
if captions:
placeholders = ','.join(['%s'] * len(captions))
cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions)
for row in cursor.fetchall():
cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id']))
_sync_attendees(cursor, meeting_id, meeting_request.attendee_ids)
connection.commit()
return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id})
@ -403,7 +431,7 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D
def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
cursor.execute("SELECT user_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
meeting = cursor.fetchone()
if not meeting:
return create_api_response(code="404", message="Meeting not found")
@ -412,17 +440,20 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre
# 使用 _process_tags 来处理标签创建
if meeting_request.tags:
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s'
cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id))
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
# 根据 caption 查找用户ID并插入参会人
if meeting_request.attendees:
captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()]
if captions:
placeholders = ','.join(['%s'] * len(captions))
cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions)
for row in cursor.fetchall():
cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id']))
update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s, prompt_id = %s WHERE meeting_id = %s'
cursor.execute(
update_query,
(
meeting_request.title,
meeting_request.meeting_time,
meeting_request.summary,
meeting_request.tags,
meeting_request.prompt_id if meeting_request.prompt_id is not None else meeting['prompt_id'],
meeting_id,
),
)
if meeting_request.attendee_ids is not None:
_sync_attendees(cursor, meeting_id, meeting_request.attendee_ids)
connection.commit()
# 同步导出总结MD文件
if meeting_request.summary:
@ -453,7 +484,7 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre
cursor = connection.cursor(dictionary=True)
query = '''
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path,
m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path,
m.access_password
FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
WHERE m.meeting_id = %s
@ -471,7 +502,9 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre
meeting_data = Meeting(
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
attendee_ids=[row['user_id'] for row in attendees_data],
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
prompt_id=meeting.get('prompt_id'),
access_password=meeting.get('access_password')
)
if meeting.get('audio_file_path'):
@ -490,6 +523,7 @@ async def upload_audio(
meeting_id: int = Form(...),
auto_summarize: str = Form("true"),
prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID
model_code: Optional[str] = Form(None), # 可选的总结模型编码
background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)
):
@ -503,6 +537,7 @@ async def upload_audio(
meeting_id: 会议ID
auto_summarize: 是否自动生成总结"true"/"false"默认"true"
prompt_id: 提示词模版ID可选如果不指定则使用默认模版
model_code: 总结模型编码可选如果不指定则使用默认模型
background_tasks: FastAPI后台任务
current_user: 当前登录用户
@ -512,14 +547,23 @@ async def upload_audio(
"""
auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes")
# 0. 如果没有传入 prompt_id尝试获取默认模版ID
model_code = model_code.strip() if model_code else None
# 0. 如果没有传入 prompt_id优先使用会议已配置模版否则回退默认模版
if prompt_id is None:
with get_db_connection() as connection:
cursor = connection.cursor()
cursor.execute(
"SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1"
)
prompt_id = cursor.fetchone()[0]
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
meeting_row = cursor.fetchone()
if meeting_row and meeting_row.get('prompt_id') and int(meeting_row['prompt_id']) > 0:
prompt_id = int(meeting_row['prompt_id'])
else:
cursor = connection.cursor()
cursor.execute(
"SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1"
)
prompt_row = cursor.fetchone()
prompt_id = prompt_row[0] if prompt_row else None
# 1. 文件类型验证
file_extension = os.path.splitext(audio_file.filename)[1].lower()
@ -572,6 +616,7 @@ async def upload_audio(
auto_summarize=auto_summarize_bool,
background_tasks=background_tasks,
prompt_id=prompt_id,
model_code=model_code,
duration=audio_duration # 传递时长参数
)
@ -605,6 +650,7 @@ async def upload_audio(
"task_id": transcription_task_id,
"transcription_started": transcription_task_id is not None,
"auto_summarize": auto_summarize_bool,
"model_code": model_code,
"replaced_existing": result["replaced_existing"],
"previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"]
}
@ -746,12 +792,17 @@ def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depen
return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}")
@router.post("/meetings/{meeting_id}/transcription/start")
def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(get_current_user)):
def start_meeting_transcription(
meeting_id: int,
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user)
):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
if not cursor.fetchone():
cursor.execute("SELECT meeting_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
meeting = cursor.fetchone()
if not meeting:
return create_api_response(code="404", message="Meeting not found")
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
audio_file = cursor.fetchone()
@ -763,6 +814,13 @@ def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(ge
"task_id": existing_status['task_id'], "status": existing_status['status']
})
task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path'])
background_tasks.add_task(
async_meeting_service.monitor_and_auto_summarize,
meeting_id,
task_id,
meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None,
None
)
return create_api_response(code="200", message="Transcription task started successfully", data={
"task_id": task_id, "meeting_id": meeting_id
})
@ -881,6 +939,12 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="Meeting not found")
transcription_status = transcription_service.get_meeting_transcription_status(meeting_id)
if transcription_status and transcription_status.get('status') in ['pending', 'processing']:
return create_api_response(code="409", message="转录进行中,暂不允许重新总结", data={
"task_id": transcription_status.get('task_id'),
"status": transcription_status.get('status')
})
# 传递 prompt_id 和 model_code 参数给服务层
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code)
background_tasks.add_task(async_meeting_service._process_task, task_id)
@ -915,7 +979,7 @@ def list_active_llm_models(current_user: dict = Depends(get_current_user)):
"SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC"
)
models = cursor.fetchall()
return create_api_response(code="200", message="获取模型列表成功", data={"models": models})
return create_api_response(code="200", message="获取模型列表成功", data=models)
except Exception as e:
return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}")
@router.get("/meetings/{meeting_id}/navigation")
@ -1023,7 +1087,7 @@ def get_meeting_navigation(
return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}")
@router.get("/meetings/{meeting_id}/preview-data")
def get_meeting_preview_data(meeting_id: int):
def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None):
"""
获取会议预览数据无需登录认证
用于二维码扫描后的预览页面
@ -1041,7 +1105,7 @@ def get_meeting_preview_data(meeting_id: int):
# 检查会议是否存在,并获取基本信息
query = '''
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id,
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, m.tags,
m.user_id as creator_id, u.caption as creator_username,
p.name as prompt_name, m.access_password
FROM meetings m
@ -1055,6 +1119,32 @@ def get_meeting_preview_data(meeting_id: int):
if not meeting:
return create_api_response(code="404", message="会议不存在")
stored_password = (meeting.get('access_password') or '').strip()
provided_password = (password or '').strip()
if stored_password:
if not provided_password:
return create_api_response(
code="401",
message="此会议受密码保护",
data={
"meeting_id": meeting_id,
"title": meeting['title'],
"requires_password": True
}
)
if provided_password != stored_password:
return create_api_response(
code="401",
message="密码错误",
data={
"meeting_id": meeting_id,
"title": meeting['title'],
"requires_password": True
}
)
# 获取整体进度状态(两阶段)
progress_info = _get_meeting_overall_status(meeting_id)
overall_status = progress_info["overall_status"]
@ -1116,6 +1206,17 @@ def get_meeting_preview_data(meeting_id: int):
cursor.execute(attendees_query, (meeting_id,))
attendees_data = cursor.fetchall()
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
cursor.execute(
'''
SELECT COUNT(DISTINCT speaker_id) AS participant_count
FROM transcript_segments
WHERE meeting_id = %s AND speaker_id IS NOT NULL
''',
(meeting_id,)
)
speaker_count_row = cursor.fetchone() or {}
participant_count = speaker_count_row.get('participant_count') or len(attendees)
tags = _process_tags(cursor, meeting.get('tags'))
# 组装返回数据
preview_data = {
@ -1127,7 +1228,8 @@ def get_meeting_preview_data(meeting_id: int):
"prompt_id": meeting['prompt_id'],
"prompt_name": meeting['prompt_name'],
"attendees": attendees,
"attendees_count": len(attendees),
"attendees_count": participant_count,
"tags": tags,
"has_password": bool(meeting.get('access_password')),
"processing_status": progress_info # 附带进度信息供调试
}

View File

@ -96,7 +96,7 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur
if current_user['role_id'] != 1: # 1 is admin
return create_api_response(code="403", message="仅管理员有权限创建用户")
if not validate_email(request.email):
if request.email and not validate_email(request.email):
return create_api_response(code="400", message="邮箱格式不正确")
with get_db_connection() as connection:

View File

@ -10,14 +10,14 @@ def get_db_connection():
connection = None
try:
connection = mysql.connector.connect(**DATABASE_CONFIG)
yield connection
except Error as e:
print(f"数据库连接错误: {e}")
raise HTTPException(status_code=500, detail="数据库连接失败")
try:
yield connection
finally:
if connection and connection.is_connected():
try:
# 确保清理任何未读结果
if connection.unread_result:
connection.consume_results()
connection.close()

View File

@ -34,9 +34,11 @@ class CreateUserRequest(BaseModel):
password: Optional[str] = None
caption: str
email: Optional[str] = None
avatar_url: Optional[str] = None
role_id: int = 2
class UpdateUserRequest(BaseModel):
username: Optional[str] = None
caption: Optional[str] = None
email: Optional[str] = None
role_id: Optional[int] = None
@ -76,16 +78,19 @@ class Meeting(BaseModel):
creator_username: str
created_at: datetime.datetime
attendees: List[AttendeeInfo]
attendee_ids: Optional[List[int]] = None
tags: List[Tag]
audio_file_path: Optional[str] = None
audio_duration: Optional[float] = None
summary: Optional[str] = None
transcription_status: Optional[TranscriptionTaskStatus] = None
llm_status: Optional[TranscriptionTaskStatus] = None
prompt_id: Optional[int] = None
prompt_name: Optional[str] = None
overall_status: Optional[str] = None
overall_progress: Optional[int] = None
current_stage: Optional[str] = None
access_password: Optional[str] = None
class TranscriptSegment(BaseModel):
segment_id: int
@ -98,7 +103,7 @@ class TranscriptSegment(BaseModel):
class CreateMeetingRequest(BaseModel):
title: str
meeting_time: datetime.datetime
attendees: str # 逗号分隔的姓名
attendee_ids: List[int]
description: Optional[str] = None
tags: Optional[str] = None # 逗号分隔
prompt_id: Optional[int] = None
@ -106,7 +111,7 @@ class CreateMeetingRequest(BaseModel):
class UpdateMeetingRequest(BaseModel):
title: Optional[str] = None
meeting_time: Optional[datetime.datetime] = None
attendees: Optional[str] = None
attendee_ids: Optional[List[int]] = None
description: Optional[str] = None
tags: Optional[str] = None
summary: Optional[str] = None
@ -121,7 +126,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel):
class TranscriptUpdateRequest(BaseModel):
segment_id: int
new_text: str
text_content: str
class BatchTranscriptUpdateRequest(BaseModel):
updates: List[TranscriptUpdateRequest]

View File

@ -127,7 +127,13 @@ class AsyncMeetingService:
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str, prompt_id: Optional[int] = None):
def monitor_and_auto_summarize(
self,
meeting_id: int,
transcription_task_id: str,
prompt_id: Optional[int] = None,
model_code: Optional[str] = None
):
"""
监控转录任务完成后自动生成总结
此方法设计为由BackgroundTasks调用在后台运行
@ -136,13 +142,14 @@ class AsyncMeetingService:
meeting_id: 会议ID
transcription_task_id: 转录任务ID
prompt_id: 提示词模版ID可选如果不指定则使用默认模版
model_code: 总结模型编码可选如果不指定则使用默认模型
流程:
1. 循环轮询转录任务状态
2. 转录成功后自动启动总结任务
3. 转录失败或超时则停止轮询并记录日志
"""
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}")
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
# 获取配置参数
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
@ -178,7 +185,12 @@ class AsyncMeetingService:
else:
# 启动总结任务
try:
summary_task_id = self.start_summary_generation(meeting_id, user_prompt="", prompt_id=prompt_id)
summary_task_id = self.start_summary_generation(
meeting_id,
user_prompt="",
prompt_id=prompt_id,
model_code=model_code
)
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
# 在后台执行总结任务
@ -395,6 +407,7 @@ class AsyncMeetingService:
'meeting_id': int(task_data.get('meeting_id', 0)),
'created_at': task_data.get('created_at'),
'updated_at': task_data.get('updated_at'),
'message': task_data.get('message'),
'result': task_data.get('result'),
'error_message': task_data.get('error_message')
}

View File

@ -25,6 +25,7 @@ def handle_audio_upload(
auto_summarize: bool = True,
background_tasks: BackgroundTasks = None,
prompt_id: int = None,
model_code: str = None,
duration: int = 0
) -> dict:
"""
@ -46,6 +47,7 @@ def handle_audio_upload(
auto_summarize: 是否自动生成总结默认True
background_tasks: FastAPI 后台任务对象
prompt_id: 提示词模版ID可选如果不指定则使用默认模版
model_code: 总结模型编码可选如果不指定则使用默认模型
duration: 音频时长
Returns:
@ -58,7 +60,7 @@ def handle_audio_upload(
"has_transcription": bool # 原来是否有转录记录 (成功时)
}
"""
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}")
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, model_code: {model_code}")
# 1. 权限和已有文件检查
try:
@ -145,9 +147,10 @@ def handle_audio_upload(
async_meeting_service.monitor_and_auto_summarize,
meeting_id,
transcription_task_id,
prompt_id # 传递 prompt_id 给自动总结监控任务
prompt_id,
model_code
)
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}")
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
except Exception as e:
print(f"Failed to start transcription: {e}")

View File

@ -15,6 +15,15 @@ class SystemConfigService:
DEFAULT_RESET_PASSWORD = 'default_reset_password'
MAX_AUDIO_SIZE = 'max_audio_size'
# 品牌配置
BRANDING_APP_NAME = 'branding_app_name'
BRANDING_HOME_HEADLINE = 'branding_home_headline'
BRANDING_HOME_TAGLINE = 'branding_home_tagline'
BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle'
BRANDING_PREVIEW_TITLE = 'branding_preview_title'
BRANDING_LOGIN_WELCOME = 'branding_login_welcome'
BRANDING_FOOTER_TEXT = 'branding_footer_text'
# 声纹配置
VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text'
VOICEPRINT_MAX_SIZE = 'voiceprint_max_size'
@ -603,6 +612,18 @@ class SystemConfigService:
except (ValueError, TypeError):
return default
@classmethod
def get_branding_config(cls) -> Dict[str, str]:
return {
"app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"),
"home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"),
"home_tagline": str(cls.get_config(cls.BRANDING_HOME_TAGLINE, "让每一次谈话都产生价值。") or "让每一次谈话都产生价值。"),
"console_subtitle": str(cls.get_config(cls.BRANDING_CONSOLE_SUBTITLE, "智能会议控制台") or "智能会议控制台"),
"preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"),
"login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"),
"footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"),
}
# LLM模型配置获取方法直接使用通用方法
@classmethod
def get_llm_model_name(cls, default: str = "qwen-plus") -> str:

View File

@ -33,6 +33,42 @@ server {
proxy_send_timeout 300s;
}
location = /docs {
proxy_pass http://backend:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://backend:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://backend:8000/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /docs/oauth2-redirect {
proxy_pass http://backend:8000/docs/oauth2-redirect;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 上传文件代理 (使用 ^~ 提高优先级,避免被静态文件正则匹配拦截)
location ^~ /uploads/ {
proxy_pass http://backend:8000;
@ -57,4 +93,4 @@ server {
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.6",
@ -19,7 +20,7 @@
"axios": "^1.6.2",
"canvg": "^4.0.3",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.2",
"jspdf": "^4.2.1",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",

View File

@ -23,7 +23,7 @@ const ContentViewer = ({
if (!content) {
return (
<Card bordered={false} style={{ borderRadius: 12 }}>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />
</Card>
);
@ -59,7 +59,7 @@ const ContentViewer = ({
];
return (
<Card bordered={false} bodyStyle={{ padding: '12px 24px' }} style={{ borderRadius: 12 }}>
<Card variant="borderless" styles={{ body: { padding: '12px 24px' } }} style={{ borderRadius: 12 }}>
<Tabs
className="console-tabs"
activeKey={activeTab}

View File

@ -10,6 +10,7 @@ import {
import { useNavigate, useLocation } from 'react-router-dom';
import menuService from '../services/menuService';
import { renderMenuIcon } from '../utils/menuIcons';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
const { Header, Content, Sider } = Layout;
const { useBreakpoint } = Grid;
@ -20,6 +21,7 @@ const MainLayout = ({ children, user, onLogout }) => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [openKeys, setOpenKeys] = useState([]);
const [activeMenuKey, setActiveMenuKey] = useState(null);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const navigate = useNavigate();
const location = useLocation();
const screens = useBreakpoint();
@ -40,6 +42,10 @@ const MainLayout = ({ children, user, onLogout }) => {
fetchMenus();
}, []);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
useEffect(() => {
if (isMobile) {
setCollapsed(false);
@ -267,8 +273,8 @@ const MainLayout = ({ children, user, onLogout }) => {
</span>
{!collapsed && (
<div>
<div className="main-layout-brand-title">iMeeting</div>
<div className="main-layout-brand-subtitle">智能会议控制台</div>
<div className="main-layout-brand-title">{branding.app_name}</div>
<div className="main-layout-brand-subtitle">{branding.console_subtitle}</div>
</div>
)}
{!isMobile && (

View File

@ -92,8 +92,8 @@ const MarkdownEditor = ({
<div className="markdown-editor-modern">
<Card
size="small"
bodyStyle={{ padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' }}
bordered={false}
styles={{ body: { padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' } }}
variant="borderless"
>
<Space split={<Divider type="vertical" />} size={4}>
<Space size={2}>
@ -132,7 +132,7 @@ const MarkdownEditor = ({
</Card>
{showPreview ? (
<Card bordered bodyStyle={{ padding: 16, minHeight: height, overflowY: 'auto' }} style={{ borderRadius: '0 0 8px 8px' }}>
<Card bordered styles={{ body: { padding: 16, minHeight: height, overflowY: 'auto' } }} style={{ borderRadius: '0 0 8px 8px' }}>
<MarkdownRenderer content={value} />
</Card>
) : (

View File

@ -7,7 +7,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { TextArea } = Input;
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) => {
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const { message } = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@ -47,7 +47,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
form.setFieldsValue({
title: meeting.title,
meeting_time: dayjs(meeting.meeting_time),
attendees: meeting.attendees?.map((a) => (typeof a === 'string' ? a : a.caption)) || [],
attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [],
prompt_id: meeting.prompt_id,
tags: meeting.tags?.map((t) => t.name) || [],
description: meeting.description,
@ -66,7 +66,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
const payload = {
...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendees: values.attendees?.join(',') || '',
attendee_ids: values.attendee_ids || [],
tags: values.tags?.join(',') || '',
};
@ -74,7 +74,6 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload);
message.success('会议更新成功');
} else {
payload.creator_id = user.user_id;
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') {
message.success('会议创建成功');
@ -87,7 +86,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
onClose();
} catch (error) {
if (!error?.errorFields) {
message.error(error?.response?.data?.message || '操作失败');
message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败');
}
} finally {
setLoading(false);
@ -127,10 +126,10 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
</Select>
</Form.Item>
<Form.Item label="参会人员" name="attendees" rules={[{ required: true, message: '请选择参会人员' }]}>
<Form.Item label="参会人员" name="attendee_ids" rules={[{ required: true, message: '请选择参会人员' }]}>
<Select mode="multiple" placeholder="选择参会人">
{users.map((u) => (
<Select.Option key={u.user_id} value={u.caption}>{u.caption}</Select.Option>
<Select.Option key={u.user_id} value={u.user_id}>{u.caption}</Select.Option>
))}
</Select>
</Form.Item>

View File

@ -5,7 +5,7 @@ import { QRCodeSVG } from 'qrcode.react';
const { Text, Paragraph } = Typography;
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) => {
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享", children }) => {
const { message } = App.useApp();
const handleCopy = () => {
@ -43,6 +43,11 @@ const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) =>
<div style={{ marginTop: 12, background: '#f5f5f5', padding: '8px 12px', borderRadius: 6, textAlign: 'left' }}>
<Text code ellipsis style={{ width: '100%', display: 'block' }}>{url}</Text>
</div>
{children ? (
<div style={{ marginTop: 20, textAlign: 'left' }}>
{children}
</div>
) : null}
</div>
</Modal>
);

View File

@ -36,6 +36,8 @@ const API_CONFIG = {
UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`,
REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`,
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`,
ACCESS_PASSWORD: (meetingId) => `/api/meetings/${meetingId}/access-password`,
PREVIEW_DATA: (meetingId) => `/api/meetings/${meetingId}/preview-data`,
LLM_MODELS: '/api/llm-models/active'
},
ADMIN: {
@ -63,6 +65,9 @@ const API_CONFIG = {
ITEM_DETAIL: (id) => `/api/admin/hot-word-items/${id}`,
}
},
PUBLIC: {
SYSTEM_CONFIG: '/api/system-config/public',
},
TAGS: {
LIST: '/api/tags'
},

View File

@ -1,3 +1,4 @@
import '@ant-design/v5-patch-for-react-19';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { StyleProvider } from '@ant-design/cssinjs';
@ -13,4 +14,4 @@ createRoot(document.getElementById('root')).render(
</AntdApp>
</StyleProvider>
</StrictMode>,
);
);

View File

@ -341,7 +341,7 @@ const AdminDashboard = () => {
<Row gutter={[16, 16]} className="admin-overview-grid">
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" bordered={false}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">用户统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon users"><UsergroupAddOutlined /></span>
@ -356,7 +356,7 @@ const AdminDashboard = () => {
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" bordered={false}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">会议统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon meetings"><VideoCameraAddOutlined /></span>
@ -371,7 +371,7 @@ const AdminDashboard = () => {
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" bordered={false}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">存储统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon storage"><DatabaseOutlined /></span>
@ -386,7 +386,7 @@ const AdminDashboard = () => {
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" bordered={false}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">服务器资源</div>
<div className="admin-overview-main">
<span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span>

View File

@ -9,7 +9,7 @@ const { Title, Paragraph } = Typography;
const ClientDownloadPage = () => {
return (
<div className="download-page-modern" style={{ maxWidth: 1000, margin: '0 auto', padding: '24px 0' }}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<FireOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
<Title level={2}>随时随地开启智能会议</Title>

View File

@ -17,7 +17,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { Title, Text } = Typography;
const { TextArea } = Input;
const CreateMeeting = ({ user }) => {
const CreateMeeting = () => {
const navigate = useNavigate();
const { message } = App.useApp();
const [form] = Form.useForm();
@ -50,8 +50,7 @@ const CreateMeeting = ({ user }) => {
const payload = {
...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendees: values.attendees.join(','),
creator_id: user.user_id
attendee_ids: values.attendee_ids
};
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') {
@ -59,7 +58,7 @@ const CreateMeeting = ({ user }) => {
navigate(`/meetings/${res.data.meeting_id}`);
}
} catch (error) {
message.error(error.response?.data?.message || '创建失败');
message.error(error.response?.data?.message || error.response?.data?.detail || '创建失败');
} finally {
setLoading(false);
}
@ -67,7 +66,7 @@ const CreateMeeting = ({ user }) => {
return (
<div className="create-meeting-modern" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
<Title level={3} style={{ margin: 0 }}>新建会议纪要</Title>
@ -93,9 +92,9 @@ const CreateMeeting = ({ user }) => {
</Col>
</Row>
<Form.Item label="参会人员" name="attendees" rules={[{ required: true, message: '请选择参会人员' }]}>
<Form.Item label="参会人员" name="attendee_ids" rules={[{ required: true, message: '请选择参会人员' }]}>
<Select mode="multiple" size="large" placeholder="选择参会人">
{users.map(u => <Select.Option key={u.user_id} value={u.caption}>{u.caption}</Select.Option>)}
{users.map(u => <Select.Option key={u.user_id} value={u.user_id}>{u.caption}</Select.Option>)}
</Select>
</Form.Item>

View File

@ -266,7 +266,7 @@ const Dashboard = ({ user }) => {
return (
<div className="dashboard-v3">
<Card bordered={false} className="dashboard-hero-card" style={{ marginBottom: 24 }}>
<Card variant="borderless" className="dashboard-hero-card" style={{ marginBottom: 24 }}>
<Row gutter={[28, 24]} align="middle">
<Col xs={24} xl={8}>
<div className="dashboard-user-block">
@ -327,7 +327,7 @@ const Dashboard = ({ user }) => {
</Row>
</Card>
<Card bordered={false} className="dashboard-main-card" bodyStyle={{ padding: 0 }}>
<Card variant="borderless" className="dashboard-main-card" styles={{ body: { padding: 0 } }}>
<div className="dashboard-toolbar">
<div className="dashboard-search-row">
<Input

View File

@ -51,7 +51,7 @@ const EditKnowledgeBase = ({ user }) => {
return (
<div className="edit-kb-modern" style={{ maxWidth: 1000, margin: '0 auto', padding: '24px 0' }}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
<Title level={3} style={{ margin: 0 }}>编辑知识库条目</Title>

View File

@ -15,7 +15,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { Title } = Typography;
const { TextArea } = Input;
const EditMeeting = ({ user }) => {
const EditMeeting = () => {
const { meeting_id } = useParams();
const navigate = useNavigate();
const { message } = App.useApp();
@ -44,7 +44,7 @@ const EditMeeting = ({ user }) => {
form.setFieldsValue({
...meeting,
meeting_time: dayjs(meeting.meeting_time),
attendees: meeting.attendees.map(a => typeof a === 'string' ? a : a.caption),
attendee_ids: meeting.attendee_ids || meeting.attendees.map(a => a.user_id).filter(Boolean),
tags: meeting.tags?.map(t => t.name) || []
});
} catch (e) {
@ -60,14 +60,14 @@ const EditMeeting = ({ user }) => {
const payload = {
...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendees: values.attendees.join(','),
attendee_ids: values.attendee_ids,
tags: values.tags?.join(',') || ''
};
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload);
message.success('会议更新成功');
navigate(`/meetings/${meeting_id}`);
} catch (error) {
message.error('更新失败');
message.error(error.response?.data?.message || error.response?.data?.detail || '更新失败');
} finally {
setLoading(false);
}
@ -75,7 +75,7 @@ const EditMeeting = ({ user }) => {
return (
<div className="edit-meeting-modern" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<Card bordered={false} loading={fetching} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<Card variant="borderless" loading={fetching} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
<Title level={3} style={{ margin: 0 }}>编辑会议信息</Title>
@ -101,9 +101,9 @@ const EditMeeting = ({ user }) => {
</Col>
</Row>
<Form.Item label="参会人员" name="attendees" rules={[{ required: true, message: '请选择参会人员' }]}>
<Form.Item label="参会人员" name="attendee_ids" rules={[{ required: true, message: '请选择参会人员' }]}>
<Select mode="multiple" size="large" placeholder="选择参会人">
{users.map(u => <Select.Option key={u.user_id} value={u.caption}>{u.caption}</Select.Option>)}
{users.map(u => <Select.Option key={u.user_id} value={u.user_id}>{u.caption}</Select.Option>)}
</Select>
</Form.Item>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Row, Col, Typography, Button,
Form, Input, Space, Tabs, App
@ -12,13 +12,19 @@ import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import menuService from '../services/menuService';
import BrandLogo from '../components/BrandLogo';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
const { Title, Paragraph, Text } = Typography;
const HomePage = ({ onLogin }) => {
const [loading, setLoading] = useState(false);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const { message } = App.useApp();
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
const handleLogin = async (values) => {
setLoading(true);
try {
@ -63,16 +69,15 @@ const HomePage = ({ onLogin }) => {
justifyContent: 'center'
}}>
<div style={{ marginBottom: 80 }}>
<BrandLogo title="iMeeting" size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
<BrandLogo title={branding.app_name} size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
</div>
<div style={{ maxWidth: 600 }}>
<Title style={{ fontSize: 56, marginBottom: 24, fontWeight: 800 }}>
智能协作 <span style={{ color: '#1677ff' }}>会议管理平台</span>
<span style={{ color: '#1677ff' }}>{branding.home_headline}</span>
</Title>
<Paragraph style={{ fontSize: 20, color: '#4e5969', lineHeight: 1.6 }}>
全流程会议辅助让每一份交流都产生价值
实时转录自动总结知识沉淀
{branding.home_tagline}
</Paragraph>
</div>
</Col>
@ -100,7 +105,7 @@ const HomePage = ({ onLogin }) => {
<div style={{ marginBottom: 40 }}>
<Paragraph type="secondary" style={{ fontSize: 16 }}>
欢迎回来请输入您的登录凭证
{branding.login_welcome}
</Paragraph>
</div>
@ -150,7 +155,7 @@ const HomePage = ({ onLogin }) => {
<div style={{ marginTop: 100, textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
©2026 iMeeting · 智能会议协作平台
{branding.footer_text}
</Text>
</div>
</div>

View File

@ -173,13 +173,13 @@ const MeetingCenterPage = ({ user }) => {
}}
>
<Card
bordered={false}
variant="borderless"
style={{
borderRadius: 20,
marginBottom: 22,
boxShadow: '0 8px 30px rgba(40, 72, 120, 0.08)',
}}
bodyStyle={{ padding: '16px 20px' }}
styles={{ body: { padding: '16px 20px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
<Space size={14} align="center">
@ -252,7 +252,7 @@ const MeetingCenterPage = ({ user }) => {
return (
<Card
key={meeting.meeting_id}
bordered={false}
variant="borderless"
hoverable
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
style={{
@ -263,7 +263,7 @@ const MeetingCenterPage = ({ user }) => {
position: 'relative',
overflow: 'hidden',
}}
bodyStyle={{ padding: 0 }}
styles={{ body: { padding: 0 } }}
>
<div
style={{

View File

@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
Tooltip, Progress, Spin, App, Dropdown,
Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select
Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
} from 'antd';
import {
ClockCircleOutlined, UserOutlined,
@ -13,7 +13,7 @@ import {
EyeOutlined, FileTextOutlined, PartitionOutlined,
SaveOutlined, CloseOutlined,
StarFilled, RobotOutlined, DownloadOutlined,
DownOutlined,
DownOutlined, CheckOutlined,
MoreOutlined, AudioOutlined
} from '@ant-design/icons';
import MarkdownRenderer from '../components/MarkdownRenderer';
@ -65,6 +65,7 @@ const MeetingDetails = ({ user }) => {
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null);
const [llmModels, setLlmModels] = useState([]);
const [selectedModelCode, setSelectedModelCode] = useState(null);
@ -75,6 +76,9 @@ const MeetingDetails = ({ user }) => {
const [showQRModal, setShowQRModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
// Drawer
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
@ -84,9 +88,26 @@ const MeetingDetails = ({ user }) => {
//
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [editingSummaryContent, setEditingSummaryContent] = useState('');
const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null);
const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null);
const [inlineSpeakerValue, setInlineSpeakerValue] = useState('');
const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null);
const [inlineSegmentValue, setInlineSegmentValue] = useState('');
const [savingInlineEdit, setSavingInlineEdit] = useState(false);
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
const isMeetingOwner = user?.user_id === meeting?.creator_id;
const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
? '请先上传音频后再总结'
: isTranscriptionRunning
? '转录进行中,完成后会自动总结'
: '';
const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading;
/* ══════════════════ 数据获取 ══════════════════ */
@ -105,6 +126,11 @@ const MeetingDetails = ({ user }) => {
setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
setMeeting(response.data);
if (response.data.prompt_id) {
setSelectedPromptId(response.data.prompt_id);
}
setAccessPasswordEnabled(Boolean(response.data.access_password));
setAccessPasswordDraft(response.data.access_password || '');
if (response.data.transcription_status) {
const ts = response.data.transcription_status;
@ -113,6 +139,17 @@ const MeetingDetails = ({ user }) => {
if (['pending', 'processing'].includes(ts.status)) {
startStatusPolling(ts.task_id);
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (response.data.llm_status) {
setSummaryTaskProgress(response.data.llm_status.progress || 0);
setSummaryTaskMessage(response.data.llm_status.message || '');
if (['pending', 'processing'].includes(response.data.llm_status.status)) {
startSummaryPolling(response.data.llm_status.task_id);
}
}
try {
@ -121,6 +158,7 @@ const MeetingDetails = ({ user }) => {
} catch { setAudioUrl(null); }
fetchTranscript();
fetchSummaryHistory();
} catch {
message.error('加载会议详情失败');
} finally {
@ -154,7 +192,13 @@ const MeetingDetails = ({ user }) => {
setTranscriptionProgress(status.progress || 0);
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
if (status.status === 'completed') fetchTranscript();
if (status.status === 'completed') {
fetchTranscript();
fetchMeetingDetails();
setTimeout(() => {
fetchSummaryHistory();
}, 1000);
}
}
} catch { clearInterval(interval); }
}, 3000);
@ -173,17 +217,73 @@ const MeetingDetails = ({ user }) => {
const fetchLlmModels = async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS));
const models = res.data.models || [];
const models = Array.isArray(res.data) ? res.data : (res.data?.models || []);
setLlmModels(models);
const def = models.find(m => m.is_default);
if (def) setSelectedModelCode(def.model_code);
} catch {}
};
const startSummaryPolling = (taskId, options = {}) => {
const { closeDrawerOnComplete = false } = options;
if (!taskId) return;
if (summaryPollInterval && activeSummaryTaskId === taskId) return;
if (summaryPollInterval) clearInterval(summaryPollInterval);
setActiveSummaryTaskId(taskId);
setSummaryLoading(true);
const poll = async () => {
try {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
const status = statusRes.data;
setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || '');
if (status.status === 'completed') {
clearInterval(interval);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
if (closeDrawerOnComplete) {
setShowSummaryDrawer(false);
}
fetchSummaryHistory();
fetchMeetingDetails();
} else if (status.status === 'failed') {
clearInterval(interval);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
message.error(status.error_message || '生成总结失败');
}
} catch (error) {
clearInterval(interval);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
message.error(error?.response?.data?.message || '获取总结状态失败');
}
};
const interval = setInterval(poll, 3000);
setSummaryPollInterval(interval);
poll();
};
const fetchSummaryHistory = async () => {
try {
const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`));
setSummaryHistory(res.data.tasks?.filter(t => t.status === 'completed') || []);
const tasks = res.data.tasks || [];
setSummaryHistory(tasks.filter(t => t.status === 'completed'));
const latestRunningTask = tasks.find(t => ['pending', 'processing'].includes(t.status));
if (latestRunningTask) {
startSummaryPolling(latestRunningTask.task_id);
} else if (!activeSummaryTaskId) {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
} catch {}
};
@ -211,6 +311,12 @@ const MeetingDetails = ({ user }) => {
formData.append('audio_file', file);
formData.append('meeting_id', meeting_id);
formData.append('force_replace', 'true');
if (meeting?.prompt_id) {
formData.append('prompt_id', String(meeting.prompt_id));
}
if (selectedModelCode) {
formData.append('model_code', selectedModelCode);
}
setIsUploading(true);
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
@ -220,10 +326,119 @@ const MeetingDetails = ({ user }) => {
finally { setIsUploading(false); }
};
const saveAccessPassword = async () => {
const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null;
if (accessPasswordEnabled && !nextPassword) {
message.warning('开启访问密码后,请先输入密码');
return;
}
setSavingAccessPassword(true);
try {
const res = await apiClient.put(
buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)),
{ password: nextPassword }
);
const savedPassword = res.data?.password || null;
setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev));
setAccessPasswordEnabled(Boolean(savedPassword));
setAccessPasswordDraft(savedPassword || '');
message.success(res.message || '访问密码已更新');
} catch (error) {
message.error(error?.response?.data?.message || '访问密码更新失败');
} finally {
setSavingAccessPassword(false);
}
};
const copyAccessPassword = async () => {
if (!accessPasswordDraft) {
message.warning('当前没有可复制的访问密码');
return;
}
await navigator.clipboard.writeText(accessPasswordDraft);
message.success('访问密码已复制');
};
const openAudioUploadPicker = () => {
document.getElementById('audio-upload-input')?.click();
};
const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => {
setInlineSpeakerEdit(speakerId);
setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`);
setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`);
};
const cancelInlineSpeakerEdit = () => {
setInlineSpeakerEdit(null);
setInlineSpeakerEditSegmentId(null);
setInlineSpeakerValue('');
};
const saveInlineSpeakerEdit = async () => {
if (inlineSpeakerEdit == null) return;
const nextTag = inlineSpeakerValue.trim();
if (!nextTag) {
message.warning('发言人名称不能为空');
return;
}
setSavingInlineEdit(true);
try {
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), {
updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }]
});
setTranscript(prev => prev.map(item => (
item.speaker_id === inlineSpeakerEdit
? { ...item, speaker_tag: nextTag }
: item
)));
setSpeakerList(prev => prev.map(item => (
item.speaker_id === inlineSpeakerEdit
? { ...item, speaker_tag: nextTag }
: item
)));
setEditingSpeakers(prev => ({ ...prev, [inlineSpeakerEdit]: nextTag }));
message.success('发言人名称已更新');
cancelInlineSpeakerEdit();
} catch (error) {
message.error(error?.response?.data?.message || '更新发言人名称失败');
} finally {
setSavingInlineEdit(false);
}
};
const startInlineSegmentEdit = (segment) => {
setInlineSegmentEditId(segment.segment_id);
setInlineSegmentValue(segment.text_content || '');
};
const cancelInlineSegmentEdit = () => {
setInlineSegmentEditId(null);
setInlineSegmentValue('');
};
const saveInlineSegmentEdit = async () => {
if (inlineSegmentEditId == null) return;
setSavingInlineEdit(true);
try {
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), {
updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }]
});
setTranscript(prev => prev.map(item => (
item.segment_id === inlineSegmentEditId
? { ...item, text_content: inlineSegmentValue }
: item
)));
message.success('转录内容已更新');
cancelInlineSegmentEdit();
} catch (error) {
message.error(error?.response?.data?.message || '更新转录内容失败');
} finally {
setSavingInlineEdit(false);
}
};
const changePlaybackRate = (nextRate) => {
setPlaybackRate(nextRate);
if (audioRef.current) {
@ -258,6 +473,18 @@ const MeetingDetails = ({ user }) => {
};
const generateSummary = async () => {
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,暂不允许重新总结');
return;
}
setSummaryLoading(true);
setSummaryTaskProgress(0);
try {
@ -266,29 +493,26 @@ const MeetingDetails = ({ user }) => {
prompt_id: selectedPromptId,
model_code: selectedModelCode
});
const taskId = res.data.task_id;
const interval = setInterval(async () => {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
const s = statusRes.data;
setSummaryTaskProgress(s.progress || 0);
setSummaryTaskMessage(s.message);
if (s.status === 'completed') {
clearInterval(interval);
setSummaryLoading(false);
setShowSummaryDrawer(false);
fetchSummaryHistory();
fetchMeetingDetails();
} else if (s.status === 'failed') {
clearInterval(interval);
setSummaryLoading(false);
message.error('生成总结失败');
}
}, 3000);
setSummaryPollInterval(interval);
} catch { setSummaryLoading(false); }
startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true });
} catch (error) {
message.error(error?.response?.data?.message || '生成总结失败');
setSummaryLoading(false);
}
};
const openSummaryDrawer = () => {
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,完成后会自动总结');
return;
}
setShowSummaryDrawer(true);
fetchSummaryHistory();
};
@ -374,7 +598,7 @@ const MeetingDetails = ({ user }) => {
<div>
{/* ── 标题 Header ── */}
<Card
bordered={false}
variant="borderless"
className="console-surface"
style={{ marginBottom: 16 }}
styles={{ body: { padding: '16px 24px' } }}
@ -404,7 +628,13 @@ const MeetingDetails = ({ user }) => {
</div>
<Space style={{ flexShrink: 0 }}>
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer}>重新总结</Button>
<Tooltip title={summaryDisabledReason}>
<span>
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
重新总结
</Button>
</span>
</Tooltip>
<Button icon={<DownloadOutlined />} onClick={downloadSummaryMd}>下载总结</Button>
<Tooltip title="分享二维码">
<Button icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
@ -418,7 +648,7 @@ const MeetingDetails = ({ user }) => {
{/* ── 转录进度条 ── */}
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
<Card bordered={false} className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
<Text>{transcriptionProgress}%</Text>
@ -442,7 +672,7 @@ const MeetingDetails = ({ user }) => {
{/* 左列: 语音转录 */}
<Col xs={24} lg={10}>
<Card
bordered={false}
variant="borderless"
className="console-surface"
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: 0 } }}
@ -534,16 +764,78 @@ const MeetingDetails = ({ user }) => {
icon={<UserOutlined />}
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
/>
<Text
strong
style={{ color: '#1677ff', cursor: 'pointer', fontSize: 13 }}
onClick={e => { e.stopPropagation(); openTranscriptEditDrawer(index); }}
>
{item.speaker_tag || `发言人 ${item.speaker_id}`}
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
</Text>
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
<Space.Compact onClick={(e) => e.stopPropagation()}>
<Input
size="small"
autoFocus
value={inlineSpeakerValue}
onChange={(e) => setInlineSpeakerValue(e.target.value)}
onPressEnter={saveInlineSpeakerEdit}
style={{ width: 180 }}
/>
<Button
size="small"
type="text"
icon={<CheckOutlined />}
loading={savingInlineEdit}
onClick={saveInlineSpeakerEdit}
/>
<Button
size="small"
type="text"
icon={<CloseOutlined />}
disabled={savingInlineEdit}
onClick={cancelInlineSpeakerEdit}
/>
</Space.Compact>
) : (
<Text
strong
style={{ color: '#1677ff', cursor: 'pointer', fontSize: 13 }}
onClick={e => {
e.stopPropagation();
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
}}
>
{item.speaker_tag || `发言人 ${item.speaker_id}`}
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
</Text>
)}
</div>
<Text style={{ fontSize: 14, lineHeight: 1.7, color: '#333' }}>{item.text_content}</Text>
{inlineSegmentEditId === item.segment_id ? (
<div onClick={(e) => e.stopPropagation()}>
<Input.TextArea
autoFocus
autoSize={{ minRows: 2, maxRows: 6 }}
value={inlineSegmentValue}
onChange={(e) => setInlineSegmentValue(e.target.value)}
onPressEnter={(e) => {
if (e.ctrlKey || e.metaKey) {
saveInlineSegmentEdit();
}
}}
/>
<Space style={{ marginTop: 8 }}>
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
保存
</Button>
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
取消
</Button>
</Space>
</div>
) : (
<Text
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: 'text' }}
onDoubleClick={(e) => {
e.stopPropagation();
startInlineSegmentEdit(item);
}}
>
{item.text_content}
</Text>
)}
</div>
),
};
@ -559,7 +851,7 @@ const MeetingDetails = ({ user }) => {
{/* 右列: AI 总结 / 思维导图 */}
<Col xs={24} lg={14}>
<Card
bordered={false}
variant="borderless"
className="console-surface"
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: '12px 0 0' } }}
@ -610,7 +902,7 @@ const MeetingDetails = ({ user }) => {
{/* 总结生成中进度条 */}
{summaryLoading && (
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
<Card bordered={false} style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
<Card variant="borderless" style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
@ -735,7 +1027,7 @@ const MeetingDetails = ({ user }) => {
{/* 生成进度 */}
{summaryLoading && (
<Card bordered={false} style={{ borderRadius: 12, background: '#f6f8fa' }}>
<Card variant="borderless" style={{ borderRadius: 12, background: '#f6f8fa' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
@ -864,7 +1156,48 @@ const MeetingDetails = ({ user }) => {
)}
</Drawer>
<QRCodeModal open={showQRModal} onClose={() => setShowQRModal(false)} url={`${window.location.origin}/meetings/preview/${meeting_id}`} />
<QRCodeModal open={showQRModal} onClose={() => setShowQRModal(false)} url={`${window.location.origin}/meetings/preview/${meeting_id}`}>
{isMeetingOwner ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>访问密码保护</Text>
<Switch
checked={accessPasswordEnabled}
checkedChildren="已开启"
unCheckedChildren="已关闭"
onChange={setAccessPasswordEnabled}
/>
</div>
{accessPasswordEnabled ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input.Password
value={accessPasswordDraft}
onChange={(e) => setAccessPasswordDraft(e.target.value)}
placeholder="请输入访问密码"
/>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Text type="secondary">开启后访客打开分享链接时需要输入这个密码</Text>
<Space>
<Button onClick={copyAccessPassword} disabled={!accessPasswordDraft}>复制密码</Button>
<Button type="primary" loading={savingAccessPassword} onClick={saveAccessPassword}>保存密码</Button>
</Space>
</Space>
</Space>
) : (
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Text type="secondary">关闭后任何拿到链接的人都可以直接查看预览页</Text>
<Button type="primary" loading={savingAccessPassword} onClick={saveAccessPassword}>关闭密码保护</Button>
</Space>
)}
</>
) : meeting?.access_password ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<Text type="secondary">该分享链接已启用访问密码密码由会议创建人管理</Text>
</>
) : null}
</QRCodeModal>
<MeetingFormDrawer
open={editDrawerOpen}
onClose={() => setEditDrawerOpen(false)}

View File

@ -1,56 +1,135 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Layout, Card, Typography, Space, Button,
Result, Spin, App, Tag, Divider, Empty, Input
} from 'antd';
import {
LockOutlined, EyeOutlined, EyeInvisibleOutlined,
CopyOutlined, CheckCircleOutlined, ShareAltOutlined,
PlayCircleFilled, PauseCircleFilled,
HomeOutlined, CalendarOutlined, LoginOutlined
} from '@ant-design/icons';
import { Layout, Space, Button, App, Tag, Empty, Input, Tabs } from 'antd';
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, UserOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownRenderer from '../components/MarkdownRenderer';
import BrandLogo from '../components/BrandLogo';
import MindMap from '../components/MindMap';
import tools from '../utils/tools';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import './MeetingPreview.css';
const { Header, Content } = Layout;
const { Title, Text, Paragraph } = Typography;
const { Content } = Layout;
const MeetingPreview = () => {
const { meeting_id } = useParams();
const { message } = App.useApp();
const audioRef = useRef(null);
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordRequired, setPasswordRequired] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState('');
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
useEffect(() => {
setMeeting(null);
setTranscript([]);
setAudioUrl('');
setActiveSegmentIndex(-1);
setError(null);
setPassword('');
setPasswordError('');
setPasswordRequired(false);
setIsAuthorized(false);
fetchPreview();
}, [meeting_id]);
const fetchPreview = async (pwd = null) => {
const fetchTranscriptAndAudio = async () => {
const [transcriptRes, audioRes] = await Promise.allSettled([
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))),
]);
if (transcriptRes.status === 'fulfilled') {
setTranscript(Array.isArray(transcriptRes.value.data) ? transcriptRes.value.data : []);
} else {
setTranscript([]);
}
if (audioRes.status === 'fulfilled') {
setAudioUrl(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`));
} else {
setAudioUrl('');
}
};
const fetchPreview = async (pwd = '') => {
setLoading(true);
try {
const url = buildApiUrl(`/api/meetings/preview/${meeting_id}${pwd ? `?password=${pwd}` : ''}`);
const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id);
const url = buildApiUrl(`${endpoint}${pwd ? `?password=${encodeURIComponent(pwd)}` : ''}`);
const res = await apiClient.get(url);
setMeeting(res.data);
setIsAuthorized(true);
setPasswordRequired(false);
setPasswordError('');
setError(null);
await fetchTranscriptAndAudio();
} catch (err) {
if (err.response?.status === 401) {
const responseCode = String(err?.response?.data?.code || '');
if (responseCode === '401') {
setMeeting(null);
setIsAuthorized(false);
setPasswordRequired(true);
setPasswordError(err?.response?.data?.message || '');
setError(null);
} else {
setError('无法加载会议预览');
setMeeting(null);
setIsAuthorized(false);
setPasswordRequired(false);
setPasswordError('');
setError(err?.response?.data?.message || '无法加载会议预览');
}
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
await navigator.clipboard.writeText(window.location.href);
message.success('分享链接已复制');
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({ title: meeting?.title || branding.preview_title, url: window.location.href });
return;
} catch {}
}
await handleCopyLink();
};
const handleTimeUpdate = () => {
if (!audioRef.current || !transcript.length) {
return;
}
const currentMs = audioRef.current.currentTime * 1000;
const index = transcript.findIndex(
(item) => currentMs >= item.start_time_ms && currentMs <= item.end_time_ms,
);
setActiveSegmentIndex(index);
};
const jumpToSegment = (segment) => {
if (!audioRef.current || !segment) {
return;
}
audioRef.current.currentTime = (segment.start_time_ms || 0) / 1000;
audioRef.current.play().catch(() => {});
};
const handleVerify = () => {
if (!password) {
message.warning('请输入访问密码');
@ -59,62 +138,191 @@ const MeetingPreview = () => {
fetchPreview(password);
};
if (loading && !meeting) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" /></div>;
if (!isAuthorized) {
if (loading && !meeting) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 400, borderRadius: 16, textAlign: 'center', boxShadow: '0 8px 24px rgba(0,0,0,0.05)' }}>
<LockOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
<Title level={3}>此会议受密码保护</Title>
<Paragraph type="secondary">请输入访问密码以查看会议纪要</Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Input.Password
size="large"
placeholder="访问密码"
value={password}
onChange={e => setPassword(e.target.value)}
onPressEnter={handleVerify}
/>
<Button type="primary" size="large" block icon={<EyeOutlined />} onClick={handleVerify}>立即查看</Button>
</Space>
<div style={{ marginTop: 24 }}>
<Link to="/"><Button type="link" icon={<HomeOutlined />}>返回首页</Button></Link>
</div>
</Card>
<div className="preview-container">
<div className="preview-loading">
<div className="loading-spinner" />
<p>正在加载会议预览...</p>
</div>
</div>
);
}
if (error) return <Result status="error" title={error} extra={<Link to="/"><Button type="primary" icon={<HomeOutlined />}>返回首页</Button></Link>} />;
if (error) {
return (
<div className="preview-container">
<div className="preview-error">
<div className="error-icon"></div>
<h2>加载失败</h2>
<p>{error}</p>
<button type="button" className="error-retry-btn" onClick={() => fetchPreview(password)}>
重试
</button>
</div>
</div>
);
}
if (passwordRequired && !isAuthorized) {
return (
<div className="preview-container">
<div className="password-protection-modal">
<div className="password-modal-content">
<div className="password-icon-large">
<LockOutlined style={{ fontSize: 36 }} />
</div>
<h2>此会议受密码保护</h2>
<p>请输入访问密码以查看会议纪要</p>
<div className="password-input-group">
<Input.Password
className={`password-input${passwordError ? ' error' : ''}`}
placeholder="访问密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (passwordError) {
setPasswordError('');
}
}}
onPressEnter={handleVerify}
/>
</div>
{passwordError ? <div className="password-error-message">{passwordError}</div> : null}
<button type="button" className="password-verify-btn" onClick={handleVerify}>
<EyeOutlined style={{ marginRight: 8 }} />
立即查看
</button>
<div style={{ marginTop: 24 }}>
<Link to="/"><Button type="link" icon={<HomeOutlined />}>返回首页</Button></Link>
</div>
</div>
</div>
</div>
);
}
return (
<Layout style={{ minHeight: '100vh', background: '#f0f2f5' }}>
<Header style={{ background: '#fff', padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}>
<Space>
<BrandLogo title="iMeeting 会议预览" size={28} titleSize={18} gap={10} />
</Space>
<Link to="/"><Button icon={<LoginOutlined />}>登录系统</Button></Link>
</Header>
<Content style={{ padding: '40px 20px', maxWidth: 900, margin: '0 auto', width: '100%' }}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Title level={2}>{meeting.title}</Title>
<Space wrap style={{ marginBottom: 20 }}>
{meeting.tags?.map(t => <Tag key={t.id} color="blue">{t.name}</Tag>)}
<Text type="secondary"><CalendarOutlined /> {tools.formatDateTime(meeting.meeting_time)}</Text>
</Space>
<Divider />
<div className="preview-content">
{meeting.summary ? (
<MarkdownRenderer content={meeting.summary} />
) : (
<Empty description="暂无会议总结内容" />
)}
<Layout style={{ minHeight: '100vh' }}>
<Content className="preview-container">
<div className="preview-content">
<h1 className="preview-title">{meeting.title}</h1>
<div className="meeting-info-section">
<h2 className="section-title">会议信息</h2>
<div className="info-item"><strong>创建人</strong>{meeting.creator_username || '未知'}</div>
<div className="info-item"><strong>会议时间</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
<div className="info-item"><strong>参会人员</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
<div className="info-item"><strong>计算人数</strong>{meeting.attendees_count || meeting.attendees?.length || 0}</div>
<div className="info-item"><strong>总结模板</strong>{meeting.prompt_name || '默认模板'}</div>
{meeting.tags?.length ? (
<div className="info-item">
<strong>标签</strong>
<Space wrap style={{ marginLeft: 8 }}>
{meeting.tags.map((tag) => <Tag key={tag.id || tag.name} color="blue">{tag.name}</Tag>)}
</Space>
</div>
) : null}
</div>
</Card>
<div className="action-buttons">
<button type="button" className="action-btn copy-btn" onClick={handleCopyLink}>
<CopyOutlined />
复制链接
</button>
<button type="button" className="action-btn share-btn" onClick={handleShare}>
<ShareAltOutlined />
立即分享
</button>
</div>
<div className="summary-section">
<Tabs
className="preview-tabs"
items={[
{
key: 'summary',
label: (
<Space size={8}>
<FileTextOutlined />
<span>会议总结</span>
</Space>
),
children: (
<div className="summary-content">
{meeting.summary ? <MarkdownRenderer content={meeting.summary} /> : <Empty description="暂无会议总结内容" />}
</div>
),
},
{
key: 'transcript',
label: (
<Space size={8}>
<AudioOutlined />
<span>会议转录</span>
</Space>
),
children: (
<div className="transcript-wrapper">
{audioUrl ? (
<div className="preview-audio-player">
<audio
ref={audioRef}
src={audioUrl}
controls
controlsList="nodownload noplaybackrate"
onTimeUpdate={handleTimeUpdate}
style={{ width: '100%' }}
/>
</div>
) : null}
{transcript.length ? (
<div className="transcript-list">
{transcript.map((segment, index) => (
<div
key={segment.segment_id}
className={`transcript-segment${activeSegmentIndex === index ? ' active' : ''}`}
onClick={() => jumpToSegment(segment)}
>
<div className="segment-header">
<span className="speaker-name">
<UserOutlined style={{ marginRight: 6 }} />
{segment.speaker_tag || `发言人 ${segment.speaker_id}`}
</span>
<span className="segment-time">{tools.formatDuration((segment.start_time_ms || 0) / 1000)}</span>
</div>
<div className="segment-text">{segment.text_content}</div>
</div>
))}
</div>
) : (
<div className="empty-transcript">暂无转录内容</div>
)}
</div>
),
},
{
key: 'mindmap',
label: (
<Space size={8}>
<PartitionOutlined />
<span>思维导图</span>
</Space>
),
children: (
<div className="mindmap-wrapper">
<MindMap content={meeting.summary} title={meeting.title} />
</div>
),
},
]}
/>
</div>
<div className="preview-footer">
{branding.footer_text}
</div>
</div>
</Content>
</Layout>
);

View File

@ -109,7 +109,7 @@ const PromptConfigPage = ({ user }) => {
return (
<div>
<Card className="console-surface" bordered={false} style={{ marginBottom: 16 }}>
<Card className="console-surface" variant="borderless" style={{ marginBottom: 16 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<div>
<Title level={3} style={{ margin: 0 }}>提示词配置</Title>
@ -117,7 +117,7 @@ const PromptConfigPage = ({ user }) => {
</div>
</Space>
</Card>
<Card className="console-surface" bordered={false} bodyStyle={{ padding: 12 }}>
<Card className="console-surface" variant="borderless" styles={{ body: { padding: 12 } }}>
<Tabs
className="console-tabs"
defaultActiveKey="config"
@ -125,7 +125,7 @@ const PromptConfigPage = ({ user }) => {
items={[
{
key: 'config',
label: '提示词配置',
label: '系统提示词配置',
children: (
<div className="console-tab-panel">
<div className="console-tab-toolbar">
@ -142,7 +142,7 @@ const PromptConfigPage = ({ user }) => {
</div>
<Row gutter={16}>
<Col xs={24} xl={14}>
<Card title="全部可用提示词" loading={loading} bordered={false} className="console-card-panel">
<Card title="全部可用提示词" loading={loading} variant="borderless" className="console-card-panel">
<Space direction="vertical" style={{ width: '100%' }}>
{availablePrompts.length ? availablePrompts.map((item) => {
const isSystem = Number(item.is_system) === 1;
@ -188,7 +188,7 @@ const PromptConfigPage = ({ user }) => {
</Card>
</Col>
<Col xs={24} xl={10}>
<Card title="已启用顺序" loading={loading} bordered={false} className="console-card-panel">
<Card title="已启用顺序" loading={loading} variant="borderless" className="console-card-panel">
<Space direction="vertical" style={{ width: '100%' }}>
{selectedPromptCards.length ? selectedPromptCards.map((item, index) => (
<Card key={item.id} size="small" style={{ borderRadius: 10 }}>

View File

@ -13,6 +13,7 @@ import {
Segmented,
Select,
Space,
Switch,
Tag,
Tooltip,
Typography,
@ -240,7 +241,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
return (
<div>
{!embedded ? (
<Card className="console-surface" bordered={false} style={{ marginBottom: 16 }}>
<Card className="console-surface" variant="borderless" style={{ marginBottom: 16 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<div>
<Title level={3} style={{ margin: 0 }}>{pageTitle}</Title>
@ -333,7 +334,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
)}
</div>
) : (
<Card className="console-surface" bordered={false}>
<Card className="console-surface" variant="borderless">
{prompts.length ? (
<>
<Row gutter={[16, 16]}>

View File

@ -236,7 +236,7 @@ const DictManagement = () => {
新增
</Button>
}
bordered={false}
variant="borderless"
>
<div style={{ marginBottom: 16 }}>
<Select
@ -300,13 +300,14 @@ const DictManagement = () => {
</Space>
)
}
bordered={false}
variant="borderless"
>
{isEditing ? (
<Form
form={form}
layout="vertical"
>
<Form
form={form}
layout="vertical"
>
{isEditing ? (
<>
<Row gutter={16}>
<Col span={12}>
<Form.Item
@ -385,13 +386,14 @@ const DictManagement = () => {
</Select>
</Form.Item>
</Space>
</Form>
) : (
<Empty
description="请从左侧树中选择一个节点进行编辑,或点击新增按钮创建新节点"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</>
) : (
<Empty
description="请从左侧树中选择一个节点进行编辑,或点击新增按钮创建新节点"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Form>
</Card>
</Col>
</Row>

View File

@ -15,6 +15,7 @@ import {
Space,
Switch,
Table,
Tag,
Tabs,
} from 'antd';
import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons';

View File

@ -675,7 +675,7 @@ const PermissionManagement = () => {
const roleManagementView = (
<Row gutter={16}>
<Col span={7}>
<Card className="console-surface" bodyStyle={{ padding: 12 }}>
<Card className="console-surface" styles={{ body: { padding: 12 } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Title level={5} style={{ margin: 0 }}>角色列表</Title>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={openCreateRoleDrawer}>新增</Button>
@ -730,7 +730,7 @@ const PermissionManagement = () => {
</Col>
<Col span={17}>
<Card className="console-surface" bodyStyle={{ padding: 12 }}>
<Card className="console-surface" styles={{ body: { padding: 12 } }}>
{!selectedRoleId ? (
<Empty description="请选择左侧角色" />
) : (
@ -763,7 +763,7 @@ const PermissionManagement = () => {
</div>
<Row gutter={14}>
<Col span={14}>
<Card size="small" bodyStyle={{ padding: 12, minHeight: 420 }}>
<Card size="small" styles={{ body: { padding: 12, minHeight: 420 } }}>
<Input.Search
allowClear
placeholder="搜索菜单名称或编码"
@ -806,7 +806,7 @@ const PermissionManagement = () => {
<Card
size="small"
title={`已选权限 (${selectedMenuRows.length})`}
bodyStyle={{ padding: 12, minHeight: 420, maxHeight: 420, overflowY: 'auto' }}
styles={{ body: { padding: 12, minHeight: 420, maxHeight: 420, overflowY: 'auto' } }}
>
{selectedMenuRows.length ? selectedMenuRows.map((menu) => (
<div key={menu.menu_id} style={{ padding: '6px 0' }}>
@ -864,7 +864,7 @@ const PermissionManagement = () => {
const menuManagementView = (
<Row gutter={16}>
<Col span={10}>
<Card className="console-surface" bodyStyle={{ padding: 12 }}>
<Card className="console-surface" styles={{ body: { padding: 12 } }}>
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
<Input.Search
allowClear
@ -918,7 +918,7 @@ const PermissionManagement = () => {
<Col span={14}>
<Card
className="console-surface"
bodyStyle={{ padding: 16, minHeight: 640 }}
styles={{ body: { padding: 16, minHeight: 640 } }}
title={
menuPanelMode === 'view' && selectedManageMenu
? `${MENU_PANEL_TITLE_MAP[menuPanelMode]} · ${selectedManageMenu.menu_name}`

View File

@ -1,11 +1,23 @@
import apiClient from './apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
export const DEFAULT_BRANDING_CONFIG = {
app_name: '智听云平台',
home_headline: '智听云平台',
home_tagline: '让每一次谈话都产生价值。',
console_subtitle: '智能会议控制台',
preview_title: '会议预览',
login_welcome: '欢迎回来,请输入您的登录凭证。',
footer_text: '©2026 智听云平台',
};
class ConfigService {
constructor() {
this.configs = null;
this.loadPromise = null;
this._pageSize = null;
this.brandingConfig = null;
this.brandingPromise = null;
}
async getConfigs() {
@ -64,6 +76,33 @@ class ConfigService {
return 10;
}
async getBrandingConfig() {
if (this.brandingConfig) {
return this.brandingConfig;
}
if (this.brandingPromise) {
return this.brandingPromise;
}
this.brandingPromise = this.loadBrandingConfigFromServer();
this.brandingConfig = await this.brandingPromise;
return this.brandingConfig;
}
async loadBrandingConfigFromServer() {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG));
return {
...DEFAULT_BRANDING_CONFIG,
...(response.data || {}),
};
} catch (error) {
console.warn('Failed to load branding configs, using defaults:', error);
return { ...DEFAULT_BRANDING_CONFIG };
}
}
// 格式化文件大小为可读格式
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
@ -78,6 +117,8 @@ class ConfigService {
this.configs = null;
this.loadPromise = null;
this._pageSize = null;
this.brandingConfig = null;
this.brandingPromise = null;
}
}

File diff suppressed because it is too large Load Diff

139
start-conda.sh 100755
View File

@ -0,0 +1,139 @@
#!/bin/bash
# Exit on error
set -e
# Project Root Directory
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$PROJECT_ROOT/backend"
FRONTEND_DIR="$PROJECT_ROOT/frontend"
# Define environment names
BACKEND_ENV="imetting_backend"
FRONTEND_ENV="imetting_frontend"
PYTHON_VERSION="3.12"
NODE_VERSION="22"
echo "========================================"
echo "Starting iMeeting with Conda"
echo "========================================"
# Try to find conda if not in PATH
if ! command -v conda &> /dev/null; then
echo "Conda not found in PATH, trying common locations..."
# Check common installation paths
CONDA_PATHS=(
"$HOME/miniconda3/bin"
"$HOME/anaconda3/bin"
"/opt/conda/bin"
"$HOME/miniconda/bin"
"$HOME/anaconda/bin"
"/usr/local/miniconda3/bin"
"/usr/local/anaconda3/bin"
)
for bin_path in "${CONDA_PATHS[@]}"; do
if [ -x "$bin_path/conda" ]; then
export PATH="$bin_path:$PATH"
echo "Found conda at: $bin_path/conda"
break
fi
done
fi
if ! command -v conda &> /dev/null; then
echo "Error: Conda is still not found."
echo "Please ensure Miniconda or Anaconda is installed and accessible."
exit 1
fi
# Initialize conda for bash script
eval "$(conda shell.bash hook)"
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main >/dev/null 2>&1 || true
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r >/dev/null 2>&1 || true
version_gte() {
[ "$(printf '%s\n' "$1" "$2" | sort -V | head -n 1)" = "$2" ]
}
node_version_supported_for_vite() {
local version="$1"
version="${version#v}"
if version_gte "$version" "22.12.0"; then
return 0
fi
if version_gte "$version" "20.19.0" && ! version_gte "$version" "21.0.0"; then
return 0
fi
return 1
}
BACKEND_PYTHON_VERSION=""
if conda info --envs | awk '{print $1}' | grep -qx "$BACKEND_ENV"; then
BACKEND_PYTHON_VERSION="$(conda run -n "$BACKEND_ENV" python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null | tail -n 1 | tr -d '[:space:]')"
if [ "$BACKEND_PYTHON_VERSION" != "$PYTHON_VERSION" ]; then
echo "Backend environment '$BACKEND_ENV' is using Python $BACKEND_PYTHON_VERSION, recreating with Python $PYTHON_VERSION..."
conda remove -y -n "$BACKEND_ENV" --all
else
echo "Backend environment '$BACKEND_ENV' already exists with Python $PYTHON_VERSION."
fi
fi
if ! conda info --envs | awk '{print $1}' | grep -qx "$BACKEND_ENV"; then
echo "Creating Conda environment '$BACKEND_ENV' with Python $PYTHON_VERSION..."
conda create -y -n "$BACKEND_ENV" python=$PYTHON_VERSION -c conda-forge --override-channels
fi
FRONTEND_NODE_VERSION=""
if conda info --envs | awk '{print $1}' | grep -qx "$FRONTEND_ENV"; then
FRONTEND_NODE_VERSION="$(conda run -n "$FRONTEND_ENV" node -p 'process.versions.node' 2>/dev/null | tail -n 1 | tr -d '[:space:]')"
if node_version_supported_for_vite "$FRONTEND_NODE_VERSION"; then
echo "Frontend environment '$FRONTEND_ENV' already exists with Node.js $FRONTEND_NODE_VERSION."
else
echo "Frontend environment '$FRONTEND_ENV' is using Node.js $FRONTEND_NODE_VERSION, recreating with Node.js $NODE_VERSION..."
conda remove -y -n "$FRONTEND_ENV" --all
fi
fi
if ! conda info --envs | awk '{print $1}' | grep -qx "$FRONTEND_ENV"; then
echo "Creating Conda environment '$FRONTEND_ENV' with Node.js $NODE_VERSION..."
conda create -y -n "$FRONTEND_ENV" nodejs=$NODE_VERSION -c conda-forge --override-channels
fi
# Start Backend in a subshell
echo "Starting backend..."
(
eval "$(conda shell.bash hook)"
conda activate "$BACKEND_ENV"
cd "$BACKEND_DIR"
pip install -r requirements.txt
python app/main.py
) &
BACKEND_PID=$!
# Start Frontend in a subshell
echo "Starting frontend..."
(
eval "$(conda shell.bash hook)"
conda activate "$FRONTEND_ENV"
cd "$FRONTEND_DIR"
npm install
npm run dev
) &
FRONTEND_PID=$!
echo "========================================"
echo "iMeeting is starting!"
echo "Backend Env: $BACKEND_ENV (PID: $BACKEND_PID)"
echo "Frontend Env: $FRONTEND_ENV (PID: $FRONTEND_PID)"
echo "Backend URL: http://localhost:8000"
echo "Frontend URL: http://localhost:5173"
echo "Press Ctrl+C to stop both services."
echo "========================================"
# Trap Ctrl+C (SIGINT) and kill both processes
trap "echo -e '\nStopping services...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM
# Wait for background processes to keep the script running
wait $BACKEND_PID $FRONTEND_PID

217
start-external.sh 100755
View File

@ -0,0 +1,217 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_banner() {
echo -e "${BLUE}"
cat << "EOF"
_ __ __ _ _
(_) \/ | ___ ___| |_(_)_ __ __ _
| | |\/| |/ _ \/ _ \ __| | '_ \ / _` |
| | | | | __/ __/ |_| | | | | (_| |
|_|_| |_|\___|\___|\__|_|_| |_|\__, |
|___/
External Middleware Deployment
EOF
echo -e "${NC}"
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
check_dependencies() {
print_info "检查系统依赖..."
if ! command -v docker &> /dev/null; then
print_error "未安装 Docker请先安装 Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
print_error "未安装 Docker Compose请先安装 Docker Compose"
exit 1
fi
print_success "系统依赖检查通过"
}
init_compose_cmd() {
if docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose"
else
COMPOSE_CMD="docker-compose"
fi
}
check_env_files() {
print_info "检查环境变量配置..."
if [ ! -f .env ]; then
print_warning ".env 文件不存在,从模板创建..."
cp .env.example .env
print_warning "请编辑 .env 文件配置访问端口、BASE_URL、QWEN_API_KEY 等参数"
print_warning "按任意键继续,或 Ctrl+C 退出..."
read -n 1 -s
fi
if [ ! -f backend/.env ]; then
print_warning "backend/.env 文件不存在,从模板创建..."
cp backend/.env.example backend/.env
print_warning "请编辑 backend/.env 文件,配置外部数据库和 Redis 参数"
print_warning "按任意键继续,或 Ctrl+C 退出..."
read -n 1 -s
fi
print_success "环境变量文件检查完成"
}
load_external_env() {
print_info "加载外部数据库与 Redis 配置..."
set -a
source backend/.env
set +a
DB_PORT="${DB_PORT:-3306}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_DB="${REDIS_DB:-0}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
local required_vars=(DB_HOST DB_USER DB_PASSWORD DB_NAME REDIS_HOST)
for var_name in "${required_vars[@]}"; do
if [ -z "${!var_name}" ]; then
print_error "backend/.env 缺少必填配置: ${var_name}"
exit 1
fi
done
print_success "外部中间件配置已加载"
}
create_directories() {
print_info "创建必要的目录..."
mkdir -p data/uploads
mkdir -p data/logs/backend
mkdir -p data/logs/frontend
mkdir -p backups
print_success "目录创建完成"
}
create_override_file() {
OVERRIDE_FILE="$(mktemp /tmp/imeeting-external-compose.XXXXXX.yml)"
trap 'rm -f "$OVERRIDE_FILE"' EXIT
cat > "$OVERRIDE_FILE" <<EOF
services:
backend:
environment:
DB_HOST: "${DB_HOST}"
DB_PORT: "${DB_PORT}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
DB_NAME: "${DB_NAME}"
REDIS_HOST: "${REDIS_HOST}"
REDIS_PORT: "${REDIS_PORT}"
REDIS_DB: "${REDIS_DB}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
EOF
}
start_services() {
print_info "启动应用服务(不启动 MySQL/Redis..."
$COMPOSE_CMD -f docker-compose.yml -f "$OVERRIDE_FILE" up -d --build --no-deps backend frontend
print_success "应用服务启动命令已执行"
}
wait_for_health() {
print_info "等待应用服务启动..."
local max_wait=120
local waited=0
while [ $waited -lt $max_wait ]; do
local healthy_count
healthy_count=$($COMPOSE_CMD -f docker-compose.yml -f "$OVERRIDE_FILE" ps --format json 2>/dev/null | grep -c '"Health":"healthy"' || echo "0")
if [ "$healthy_count" -eq 2 ]; then
print_success "前后端服务已就绪"
return 0
fi
echo -ne "\r等待中... (${waited}s/${max_wait}s) 健康: ${healthy_count}/2"
sleep 5
waited=$((waited + 5))
done
echo ""
print_warning "服务启动超时,请手动检查状态"
return 1
}
show_status() {
print_info "服务状态:"
$COMPOSE_CMD ps backend frontend
}
show_access_info() {
echo ""
print_success "==================================="
print_success " iMeeting 外部中间件模式部署完成!"
print_success "==================================="
echo ""
echo -e "${GREEN}访问地址:${NC}"
echo -e " HTTP访问: ${BLUE}http://localhost${NC}"
echo -e " API文档: ${BLUE}http://localhost/docs${NC}"
echo ""
echo -e "${YELLOW}当前模式:${NC}"
echo -e " 仅启动: ${BLUE}backend + frontend${NC}"
echo -e " 外部依赖: ${BLUE}backend/.env 中配置的 MySQL / Redis${NC}"
echo ""
echo -e "${YELLOW}常用命令:${NC}"
echo -e " 查看日志: ${BLUE}$COMPOSE_CMD logs -f backend frontend${NC}"
echo -e " 停止服务: ${BLUE}./stop-external.sh${NC}"
echo -e " 查看状态: ${BLUE}$COMPOSE_CMD ps backend frontend${NC}"
echo ""
}
main() {
print_banner
check_dependencies
init_compose_cmd
check_env_files
load_external_env
create_directories
create_override_file
start_services
echo ""
wait_for_health
echo ""
show_status
show_access_info
}
main "$@"

57
stop-external.sh 100755
View File

@ -0,0 +1,57 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose"
else
COMPOSE_CMD="docker-compose"
fi
print_info "当前应用服务状态:"
$COMPOSE_CMD ps backend frontend
echo ""
echo -e "${YELLOW}是否保留应用容器?${NC}"
echo "1) 仅停止 backend/frontend保留容器"
echo "2) 停止并删除 backend/frontend 容器"
read -p "请选择 (1/2): " choice
case $choice in
1)
print_info "停止应用服务..."
$COMPOSE_CMD stop backend frontend
print_success "应用服务已停止,外部数据库和 Redis 未受影响"
;;
2)
print_info "停止并删除应用容器..."
$COMPOSE_CMD stop backend frontend
$COMPOSE_CMD rm -f backend frontend
print_success "应用容器已删除,外部数据库和 Redis 未受影响"
;;
*)
print_warning "无效选择,仅停止应用服务"
$COMPOSE_CMD stop backend frontend
;;
esac