Merge remote-tracking branch 'origin/alan-dev' into codex/dev
commit
cec4f98d42
17
README.md
17
README.md
|
|
@ -82,8 +82,8 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- Node.js 16+
|
- Node.js 22.12+
|
||||||
- Python 3.9+
|
- Python 3.12+
|
||||||
- MySQL 5.7+
|
- MySQL 5.7+
|
||||||
- Redis 5.0+
|
- Redis 5.0+
|
||||||
- Docker (可选)
|
- Docker (可选)
|
||||||
|
|
@ -95,7 +95,7 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python main.py
|
python app/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
默认运行在 `http://localhost:8000`
|
默认运行在 `http://localhost:8000`
|
||||||
|
|
@ -110,6 +110,17 @@ npm run dev
|
||||||
|
|
||||||
默认运行在 `http://localhost:5173`
|
默认运行在 `http://localhost:5173`
|
||||||
|
|
||||||
|
#### 使用 Conda 一键启动前后端
|
||||||
|
|
||||||
|
项目根目录提供了 Conda 启动脚本,会分别创建并使用独立环境启动前后端:
|
||||||
|
|
||||||
|
- 后端环境: `imetting_backend` (Python 3.12)
|
||||||
|
- 前端环境: `imetting_frontend` (Node.js 22)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start-conda.sh
|
||||||
|
```
|
||||||
|
|
||||||
### 配置说明
|
### 配置说明
|
||||||
|
|
||||||
详细的配置文档请参考:
|
详细的配置文档请参考:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from app.core.database import get_db_connection
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||||
from app.services.llm_service import LLMService
|
from app.services.llm_service import LLMService
|
||||||
|
from app.services.system_config_service import SystemConfigService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
llm_service = LLMService()
|
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)}")
|
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")
|
@router.get("/admin/system-config")
|
||||||
async def get_system_config_compat(current_user=Depends(get_current_admin_user)):
|
async def get_system_config_compat(current_user=Depends(get_current_admin_user)):
|
||||||
"""兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。"""
|
"""兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。"""
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,19 @@ def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] =
|
||||||
tags_data = cursor.fetchall()
|
tags_data = cursor.fetchall()
|
||||||
return [Tag(**tag) for tag in tags_data]
|
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:
|
def _get_meeting_overall_status(meeting_id: int) -> dict:
|
||||||
"""
|
"""
|
||||||
获取会议的整体进度状态(包含转译和LLM两个阶段)
|
获取会议的整体进度状态(包含转译和LLM两个阶段)
|
||||||
|
|
@ -248,7 +261,7 @@ def get_meetings(
|
||||||
meeting_list.append(Meeting(
|
meeting_list.append(Meeting(
|
||||||
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
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'],
|
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'),
|
access_password=meeting.get('access_password'),
|
||||||
overall_status=progress_info.get('overall_status'),
|
overall_status=progress_info.get('overall_status'),
|
||||||
overall_progress=progress_info.get('overall_progress'),
|
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)
|
cursor = connection.cursor(dictionary=True)
|
||||||
query = '''
|
query = '''
|
||||||
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
|
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,
|
af.file_path as audio_file_path, af.duration as audio_duration,
|
||||||
p.name as prompt_name, m.access_password
|
p.name as prompt_name, m.access_password
|
||||||
FROM meetings m
|
FROM meetings m
|
||||||
|
|
@ -341,7 +354,9 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
|
||||||
meeting_data = Meeting(
|
meeting_data = Meeting(
|
||||||
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
||||||
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
|
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,
|
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
|
||||||
|
prompt_id=meeting.get('prompt_id'),
|
||||||
prompt_name=meeting.get('prompt_name'),
|
prompt_name=meeting.get('prompt_name'),
|
||||||
access_password=meeting.get('access_password')
|
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)
|
meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {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)
|
return create_api_response(code="200", message="获取会议详情成功", data=meeting_data)
|
||||||
|
|
||||||
@router.get("/meetings/{meeting_id}/transcript")
|
@router.get("/meetings/{meeting_id}/transcript")
|
||||||
|
|
@ -385,17 +406,24 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D
|
||||||
# 使用 _process_tags 来处理标签创建
|
# 使用 _process_tags 来处理标签创建
|
||||||
if meeting_request.tags:
|
if meeting_request.tags:
|
||||||
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
|
_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)'
|
meeting_query = '''
|
||||||
cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat()))
|
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
|
meeting_id = cursor.lastrowid
|
||||||
# 根据 caption 查找用户ID并插入参会人
|
_sync_attendees(cursor, meeting_id, meeting_request.attendee_ids)
|
||||||
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']))
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id})
|
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)):
|
def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)):
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
meeting = cursor.fetchone()
|
||||||
if not meeting:
|
if not meeting:
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
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 来处理标签创建
|
# 使用 _process_tags 来处理标签创建
|
||||||
if meeting_request.tags:
|
if meeting_request.tags:
|
||||||
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
|
_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'
|
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_id))
|
cursor.execute(
|
||||||
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
|
update_query,
|
||||||
# 根据 caption 查找用户ID并插入参会人
|
(
|
||||||
if meeting_request.attendees:
|
meeting_request.title,
|
||||||
captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()]
|
meeting_request.meeting_time,
|
||||||
if captions:
|
meeting_request.summary,
|
||||||
placeholders = ','.join(['%s'] * len(captions))
|
meeting_request.tags,
|
||||||
cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions)
|
meeting_request.prompt_id if meeting_request.prompt_id is not None else meeting['prompt_id'],
|
||||||
for row in cursor.fetchall():
|
meeting_id,
|
||||||
cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id']))
|
),
|
||||||
|
)
|
||||||
|
if meeting_request.attendee_ids is not None:
|
||||||
|
_sync_attendees(cursor, meeting_id, meeting_request.attendee_ids)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
# 同步导出总结MD文件
|
# 同步导出总结MD文件
|
||||||
if meeting_request.summary:
|
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)
|
cursor = connection.cursor(dictionary=True)
|
||||||
query = '''
|
query = '''
|
||||||
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
|
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
|
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
|
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
|
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_data = Meeting(
|
||||||
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
||||||
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
|
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,
|
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
|
||||||
|
prompt_id=meeting.get('prompt_id'),
|
||||||
access_password=meeting.get('access_password')
|
access_password=meeting.get('access_password')
|
||||||
)
|
)
|
||||||
if meeting.get('audio_file_path'):
|
if meeting.get('audio_file_path'):
|
||||||
|
|
@ -490,6 +523,7 @@ async def upload_audio(
|
||||||
meeting_id: int = Form(...),
|
meeting_id: int = Form(...),
|
||||||
auto_summarize: str = Form("true"),
|
auto_summarize: str = Form("true"),
|
||||||
prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID
|
prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID
|
||||||
|
model_code: Optional[str] = Form(None), # 可选的总结模型编码
|
||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
|
@ -503,6 +537,7 @@ async def upload_audio(
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
auto_summarize: 是否自动生成总结("true"/"false",默认"true")
|
auto_summarize: 是否自动生成总结("true"/"false",默认"true")
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
background_tasks: FastAPI后台任务
|
background_tasks: FastAPI后台任务
|
||||||
current_user: 当前登录用户
|
current_user: 当前登录用户
|
||||||
|
|
||||||
|
|
@ -512,14 +547,23 @@ async def upload_audio(
|
||||||
"""
|
"""
|
||||||
auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes")
|
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:
|
if prompt_id is None:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute(
|
cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||||
"SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1"
|
meeting_row = cursor.fetchone()
|
||||||
)
|
if meeting_row and meeting_row.get('prompt_id') and int(meeting_row['prompt_id']) > 0:
|
||||||
prompt_id = cursor.fetchone()[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. 文件类型验证
|
# 1. 文件类型验证
|
||||||
file_extension = os.path.splitext(audio_file.filename)[1].lower()
|
file_extension = os.path.splitext(audio_file.filename)[1].lower()
|
||||||
|
|
@ -572,6 +616,7 @@ async def upload_audio(
|
||||||
auto_summarize=auto_summarize_bool,
|
auto_summarize=auto_summarize_bool,
|
||||||
background_tasks=background_tasks,
|
background_tasks=background_tasks,
|
||||||
prompt_id=prompt_id,
|
prompt_id=prompt_id,
|
||||||
|
model_code=model_code,
|
||||||
duration=audio_duration # 传递时长参数
|
duration=audio_duration # 传递时长参数
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -605,6 +650,7 @@ async def upload_audio(
|
||||||
"task_id": transcription_task_id,
|
"task_id": transcription_task_id,
|
||||||
"transcription_started": transcription_task_id is not None,
|
"transcription_started": transcription_task_id is not None,
|
||||||
"auto_summarize": auto_summarize_bool,
|
"auto_summarize": auto_summarize_bool,
|
||||||
|
"model_code": model_code,
|
||||||
"replaced_existing": result["replaced_existing"],
|
"replaced_existing": result["replaced_existing"],
|
||||||
"previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"]
|
"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)}")
|
return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}")
|
||||||
|
|
||||||
@router.post("/meetings/{meeting_id}/transcription/start")
|
@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:
|
try:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
cursor.execute("SELECT meeting_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||||
if not cursor.fetchone():
|
meeting = cursor.fetchone()
|
||||||
|
if not meeting:
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
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,))
|
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
||||||
audio_file = cursor.fetchone()
|
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": existing_status['task_id'], "status": existing_status['status']
|
||||||
})
|
})
|
||||||
task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path'])
|
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={
|
return create_api_response(code="200", message="Transcription task started successfully", data={
|
||||||
"task_id": task_id, "meeting_id": meeting_id
|
"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,))
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
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 参数给服务层
|
# 传递 prompt_id 和 model_code 参数给服务层
|
||||||
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.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)
|
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"
|
"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()
|
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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}")
|
return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}")
|
||||||
@router.get("/meetings/{meeting_id}/navigation")
|
@router.get("/meetings/{meeting_id}/navigation")
|
||||||
|
|
@ -1023,7 +1087,7 @@ def get_meeting_navigation(
|
||||||
return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}")
|
return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}")
|
||||||
|
|
||||||
@router.get("/meetings/{meeting_id}/preview-data")
|
@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 = '''
|
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,
|
m.user_id as creator_id, u.caption as creator_username,
|
||||||
p.name as prompt_name, m.access_password
|
p.name as prompt_name, m.access_password
|
||||||
FROM meetings m
|
FROM meetings m
|
||||||
|
|
@ -1055,6 +1119,32 @@ def get_meeting_preview_data(meeting_id: int):
|
||||||
if not meeting:
|
if not meeting:
|
||||||
return create_api_response(code="404", message="会议不存在")
|
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)
|
progress_info = _get_meeting_overall_status(meeting_id)
|
||||||
overall_status = progress_info["overall_status"]
|
overall_status = progress_info["overall_status"]
|
||||||
|
|
@ -1116,6 +1206,17 @@ def get_meeting_preview_data(meeting_id: int):
|
||||||
cursor.execute(attendees_query, (meeting_id,))
|
cursor.execute(attendees_query, (meeting_id,))
|
||||||
attendees_data = cursor.fetchall()
|
attendees_data = cursor.fetchall()
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
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 = {
|
preview_data = {
|
||||||
|
|
@ -1127,7 +1228,8 @@ def get_meeting_preview_data(meeting_id: int):
|
||||||
"prompt_id": meeting['prompt_id'],
|
"prompt_id": meeting['prompt_id'],
|
||||||
"prompt_name": meeting['prompt_name'],
|
"prompt_name": meeting['prompt_name'],
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"attendees_count": len(attendees),
|
"attendees_count": participant_count,
|
||||||
|
"tags": tags,
|
||||||
"has_password": bool(meeting.get('access_password')),
|
"has_password": bool(meeting.get('access_password')),
|
||||||
"processing_status": progress_info # 附带进度信息供调试
|
"processing_status": progress_info # 附带进度信息供调试
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur
|
||||||
if current_user['role_id'] != 1: # 1 is admin
|
if current_user['role_id'] != 1: # 1 is admin
|
||||||
return create_api_response(code="403", message="仅管理员有权限创建用户")
|
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="邮箱格式不正确")
|
return create_api_response(code="400", message="邮箱格式不正确")
|
||||||
|
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ def get_db_connection():
|
||||||
connection = None
|
connection = None
|
||||||
try:
|
try:
|
||||||
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
||||||
yield connection
|
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(f"数据库连接错误: {e}")
|
print(f"数据库连接错误: {e}")
|
||||||
raise HTTPException(status_code=500, detail="数据库连接失败")
|
raise HTTPException(status_code=500, detail="数据库连接失败")
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
finally:
|
finally:
|
||||||
if connection and connection.is_connected():
|
if connection and connection.is_connected():
|
||||||
try:
|
try:
|
||||||
# 确保清理任何未读结果
|
|
||||||
if connection.unread_result:
|
if connection.unread_result:
|
||||||
connection.consume_results()
|
connection.consume_results()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,11 @@ class CreateUserRequest(BaseModel):
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
caption: str
|
caption: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
role_id: int = 2
|
role_id: int = 2
|
||||||
|
|
||||||
class UpdateUserRequest(BaseModel):
|
class UpdateUserRequest(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
caption: Optional[str] = None
|
caption: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
|
|
@ -76,16 +78,19 @@ class Meeting(BaseModel):
|
||||||
creator_username: str
|
creator_username: str
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
attendees: List[AttendeeInfo]
|
attendees: List[AttendeeInfo]
|
||||||
|
attendee_ids: Optional[List[int]] = None
|
||||||
tags: List[Tag]
|
tags: List[Tag]
|
||||||
audio_file_path: Optional[str] = None
|
audio_file_path: Optional[str] = None
|
||||||
audio_duration: Optional[float] = None
|
audio_duration: Optional[float] = None
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
transcription_status: Optional[TranscriptionTaskStatus] = None
|
transcription_status: Optional[TranscriptionTaskStatus] = None
|
||||||
|
llm_status: Optional[TranscriptionTaskStatus] = None
|
||||||
prompt_id: Optional[int] = None
|
prompt_id: Optional[int] = None
|
||||||
prompt_name: Optional[str] = None
|
prompt_name: Optional[str] = None
|
||||||
overall_status: Optional[str] = None
|
overall_status: Optional[str] = None
|
||||||
overall_progress: Optional[int] = None
|
overall_progress: Optional[int] = None
|
||||||
current_stage: Optional[str] = None
|
current_stage: Optional[str] = None
|
||||||
|
access_password: Optional[str] = None
|
||||||
|
|
||||||
class TranscriptSegment(BaseModel):
|
class TranscriptSegment(BaseModel):
|
||||||
segment_id: int
|
segment_id: int
|
||||||
|
|
@ -98,7 +103,7 @@ class TranscriptSegment(BaseModel):
|
||||||
class CreateMeetingRequest(BaseModel):
|
class CreateMeetingRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
meeting_time: datetime.datetime
|
meeting_time: datetime.datetime
|
||||||
attendees: str # 逗号分隔的姓名
|
attendee_ids: List[int]
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tags: Optional[str] = None # 逗号分隔
|
tags: Optional[str] = None # 逗号分隔
|
||||||
prompt_id: Optional[int] = None
|
prompt_id: Optional[int] = None
|
||||||
|
|
@ -106,7 +111,7 @@ class CreateMeetingRequest(BaseModel):
|
||||||
class UpdateMeetingRequest(BaseModel):
|
class UpdateMeetingRequest(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
meeting_time: Optional[datetime.datetime] = None
|
meeting_time: Optional[datetime.datetime] = None
|
||||||
attendees: Optional[str] = None
|
attendee_ids: Optional[List[int]] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
|
|
@ -121,7 +126,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||||
|
|
||||||
class TranscriptUpdateRequest(BaseModel):
|
class TranscriptUpdateRequest(BaseModel):
|
||||||
segment_id: int
|
segment_id: int
|
||||||
new_text: str
|
text_content: str
|
||||||
|
|
||||||
class BatchTranscriptUpdateRequest(BaseModel):
|
class BatchTranscriptUpdateRequest(BaseModel):
|
||||||
updates: List[TranscriptUpdateRequest]
|
updates: List[TranscriptUpdateRequest]
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,13 @@ class AsyncMeetingService:
|
||||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
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)
|
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调用,在后台运行
|
此方法设计为由BackgroundTasks调用,在后台运行
|
||||||
|
|
@ -136,13 +142,14 @@ class AsyncMeetingService:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
transcription_task_id: 转录任务ID
|
transcription_task_id: 转录任务ID
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1. 循环轮询转录任务状态
|
1. 循环轮询转录任务状态
|
||||||
2. 转录成功后自动启动总结任务
|
2. 转录成功后自动启动总结任务
|
||||||
3. 转录失败或超时则停止轮询并记录日志
|
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']
|
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
||||||
|
|
@ -178,7 +185,12 @@ class AsyncMeetingService:
|
||||||
else:
|
else:
|
||||||
# 启动总结任务
|
# 启动总结任务
|
||||||
try:
|
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}")
|
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)),
|
'meeting_id': int(task_data.get('meeting_id', 0)),
|
||||||
'created_at': task_data.get('created_at'),
|
'created_at': task_data.get('created_at'),
|
||||||
'updated_at': task_data.get('updated_at'),
|
'updated_at': task_data.get('updated_at'),
|
||||||
|
'message': task_data.get('message'),
|
||||||
'result': task_data.get('result'),
|
'result': task_data.get('result'),
|
||||||
'error_message': task_data.get('error_message')
|
'error_message': task_data.get('error_message')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ def handle_audio_upload(
|
||||||
auto_summarize: bool = True,
|
auto_summarize: bool = True,
|
||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
prompt_id: int = None,
|
prompt_id: int = None,
|
||||||
|
model_code: str = None,
|
||||||
duration: int = 0
|
duration: int = 0
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|
@ -46,6 +47,7 @@ def handle_audio_upload(
|
||||||
auto_summarize: 是否自动生成总结(默认True)
|
auto_summarize: 是否自动生成总结(默认True)
|
||||||
background_tasks: FastAPI 后台任务对象
|
background_tasks: FastAPI 后台任务对象
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
duration: 音频时长(秒)
|
duration: 音频时长(秒)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -58,7 +60,7 @@ def handle_audio_upload(
|
||||||
"has_transcription": bool # 原来是否有转录记录 (成功时)
|
"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. 权限和已有文件检查
|
# 1. 权限和已有文件检查
|
||||||
try:
|
try:
|
||||||
|
|
@ -145,9 +147,10 @@ def handle_audio_upload(
|
||||||
async_meeting_service.monitor_and_auto_summarize,
|
async_meeting_service.monitor_and_auto_summarize,
|
||||||
meeting_id,
|
meeting_id,
|
||||||
transcription_task_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:
|
except Exception as e:
|
||||||
print(f"Failed to start transcription: {e}")
|
print(f"Failed to start transcription: {e}")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ class SystemConfigService:
|
||||||
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
||||||
MAX_AUDIO_SIZE = 'max_audio_size'
|
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_TEMPLATE_TEXT = 'voiceprint_template_text'
|
||||||
VOICEPRINT_MAX_SIZE = 'voiceprint_max_size'
|
VOICEPRINT_MAX_SIZE = 'voiceprint_max_size'
|
||||||
|
|
@ -603,6 +612,18 @@ class SystemConfigService:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
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模型配置获取方法(直接使用通用方法)
|
# LLM模型配置获取方法(直接使用通用方法)
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_llm_model_name(cls, default: str = "qwen-plus") -> str:
|
def get_llm_model_name(cls, default: str = "qwen-plus") -> str:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,42 @@ server {
|
||||||
proxy_send_timeout 300s;
|
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/ {
|
location ^~ /uploads/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
|
@ -57,4 +93,4 @@ server {
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.6",
|
"@codemirror/view": "^6.38.6",
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"canvg": "^4.0.3",
|
"canvg": "^4.0.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^3.0.2",
|
"jspdf": "^4.2.1",
|
||||||
"markmap-common": "^0.18.9",
|
"markmap-common": "^0.18.9",
|
||||||
"markmap-lib": "^0.18.12",
|
"markmap-lib": "^0.18.12",
|
||||||
"markmap-view": "^0.18.12",
|
"markmap-view": "^0.18.12",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const ContentViewer = ({
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -59,7 +59,7 @@ const ContentViewer = ({
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bordered={false} bodyStyle={{ padding: '12px 24px' }} style={{ borderRadius: 12 }}>
|
<Card variant="borderless" styles={{ body: { padding: '12px 24px' } }} style={{ borderRadius: 12 }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
className="console-tabs"
|
className="console-tabs"
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import menuService from '../services/menuService';
|
import menuService from '../services/menuService';
|
||||||
import { renderMenuIcon } from '../utils/menuIcons';
|
import { renderMenuIcon } from '../utils/menuIcons';
|
||||||
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
@ -20,6 +21,7 @@ const MainLayout = ({ children, user, onLogout }) => {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [openKeys, setOpenKeys] = useState([]);
|
const [openKeys, setOpenKeys] = useState([]);
|
||||||
const [activeMenuKey, setActiveMenuKey] = useState(null);
|
const [activeMenuKey, setActiveMenuKey] = useState(null);
|
||||||
|
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
|
@ -40,6 +42,10 @@ const MainLayout = ({ children, user, onLogout }) => {
|
||||||
fetchMenus();
|
fetchMenus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
|
|
@ -267,8 +273,8 @@ const MainLayout = ({ children, user, onLogout }) => {
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div>
|
<div>
|
||||||
<div className="main-layout-brand-title">iMeeting</div>
|
<div className="main-layout-brand-title">{branding.app_name}</div>
|
||||||
<div className="main-layout-brand-subtitle">智能会议控制台</div>
|
<div className="main-layout-brand-subtitle">{branding.console_subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,8 @@ const MarkdownEditor = ({
|
||||||
<div className="markdown-editor-modern">
|
<div className="markdown-editor-modern">
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
bodyStyle={{ padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' }}
|
styles={{ body: { padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' } }}
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
>
|
>
|
||||||
<Space split={<Divider type="vertical" />} size={4}>
|
<Space split={<Divider type="vertical" />} size={4}>
|
||||||
<Space size={2}>
|
<Space size={2}>
|
||||||
|
|
@ -132,7 +132,7 @@ const MarkdownEditor = ({
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{showPreview ? (
|
{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} />
|
<MarkdownRenderer content={value} />
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) => {
|
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -47,7 +47,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
title: meeting.title,
|
title: meeting.title,
|
||||||
meeting_time: dayjs(meeting.meeting_time),
|
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,
|
prompt_id: meeting.prompt_id,
|
||||||
tags: meeting.tags?.map((t) => t.name) || [],
|
tags: meeting.tags?.map((t) => t.name) || [],
|
||||||
description: meeting.description,
|
description: meeting.description,
|
||||||
|
|
@ -66,7 +66,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
|
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(',') || '',
|
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);
|
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload);
|
||||||
message.success('会议更新成功');
|
message.success('会议更新成功');
|
||||||
} else {
|
} else {
|
||||||
payload.creator_id = user.user_id;
|
|
||||||
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
|
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
|
||||||
if (res.code === '200') {
|
if (res.code === '200') {
|
||||||
message.success('会议创建成功');
|
message.success('会议创建成功');
|
||||||
|
|
@ -87,7 +86,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!error?.errorFields) {
|
if (!error?.errorFields) {
|
||||||
message.error(error?.response?.data?.message || '操作失败');
|
message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -127,10 +126,10 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user })
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</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="选择参会人">
|
<Select mode="multiple" placeholder="选择参会人">
|
||||||
{users.map((u) => (
|
{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>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) => {
|
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享", children }) => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
const handleCopy = () => {
|
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' }}>
|
<div style={{ marginTop: 12, background: '#f5f5f5', padding: '8px 12px', borderRadius: 6, textAlign: 'left' }}>
|
||||||
<Text code ellipsis style={{ width: '100%', display: 'block' }}>{url}</Text>
|
<Text code ellipsis style={{ width: '100%', display: 'block' }}>{url}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
{children ? (
|
||||||
|
<div style={{ marginTop: 20, textAlign: 'left' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ const API_CONFIG = {
|
||||||
UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`,
|
UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`,
|
||||||
REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`,
|
REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`,
|
||||||
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`,
|
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'
|
LLM_MODELS: '/api/llm-models/active'
|
||||||
},
|
},
|
||||||
ADMIN: {
|
ADMIN: {
|
||||||
|
|
@ -63,6 +65,9 @@ const API_CONFIG = {
|
||||||
ITEM_DETAIL: (id) => `/api/admin/hot-word-items/${id}`,
|
ITEM_DETAIL: (id) => `/api/admin/hot-word-items/${id}`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
PUBLIC: {
|
||||||
|
SYSTEM_CONFIG: '/api/system-config/public',
|
||||||
|
},
|
||||||
TAGS: {
|
TAGS: {
|
||||||
LIST: '/api/tags'
|
LIST: '/api/tags'
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import '@ant-design/v5-patch-for-react-19';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { StyleProvider } from '@ant-design/cssinjs';
|
import { StyleProvider } from '@ant-design/cssinjs';
|
||||||
|
|
@ -13,4 +14,4 @@ createRoot(document.getElementById('root')).render(
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</StyleProvider>
|
</StyleProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ const AdminDashboard = () => {
|
||||||
|
|
||||||
<Row gutter={[16, 16]} className="admin-overview-grid">
|
<Row gutter={[16, 16]} className="admin-overview-grid">
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<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-title">用户统计</div>
|
||||||
<div className="admin-overview-main">
|
<div className="admin-overview-main">
|
||||||
<span className="admin-overview-icon users"><UsergroupAddOutlined /></span>
|
<span className="admin-overview-icon users"><UsergroupAddOutlined /></span>
|
||||||
|
|
@ -356,7 +356,7 @@ const AdminDashboard = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<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-title">会议统计</div>
|
||||||
<div className="admin-overview-main">
|
<div className="admin-overview-main">
|
||||||
<span className="admin-overview-icon meetings"><VideoCameraAddOutlined /></span>
|
<span className="admin-overview-icon meetings"><VideoCameraAddOutlined /></span>
|
||||||
|
|
@ -371,7 +371,7 @@ const AdminDashboard = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<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-title">存储统计</div>
|
||||||
<div className="admin-overview-main">
|
<div className="admin-overview-main">
|
||||||
<span className="admin-overview-icon storage"><DatabaseOutlined /></span>
|
<span className="admin-overview-icon storage"><DatabaseOutlined /></span>
|
||||||
|
|
@ -386,7 +386,7 @@ const AdminDashboard = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<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-title">服务器资源</div>
|
||||||
<div className="admin-overview-main">
|
<div className="admin-overview-main">
|
||||||
<span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span>
|
<span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const { Title, Paragraph } = Typography;
|
||||||
const ClientDownloadPage = () => {
|
const ClientDownloadPage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="download-page-modern" style={{ maxWidth: 1000, margin: '0 auto', padding: '24px 0' }}>
|
<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' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
<FireOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
|
<FireOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
|
||||||
<Title level={2}>随时随地,开启智能会议</Title>
|
<Title level={2}>随时随地,开启智能会议</Title>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
const CreateMeeting = ({ user }) => {
|
const CreateMeeting = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
@ -50,8 +50,7 @@ const CreateMeeting = ({ user }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
|
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
attendees: values.attendees.join(','),
|
attendee_ids: values.attendee_ids
|
||||||
creator_id: user.user_id
|
|
||||||
};
|
};
|
||||||
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
|
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
|
||||||
if (res.code === '200') {
|
if (res.code === '200') {
|
||||||
|
|
@ -59,7 +58,7 @@ const CreateMeeting = ({ user }) => {
|
||||||
navigate(`/meetings/${res.data.meeting_id}`);
|
navigate(`/meetings/${res.data.meeting_id}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.message || '创建失败');
|
message.error(error.response?.data?.message || error.response?.data?.detail || '创建失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +66,7 @@ const CreateMeeting = ({ user }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-meeting-modern" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
|
||||||
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
||||||
<Title level={3} style={{ margin: 0 }}>新建会议纪要</Title>
|
<Title level={3} style={{ margin: 0 }}>新建会议纪要</Title>
|
||||||
|
|
@ -93,9 +92,9 @@ const CreateMeeting = ({ user }) => {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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="选择参会人">
|
<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>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ const Dashboard = ({ user }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-v3">
|
<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">
|
<Row gutter={[28, 24]} align="middle">
|
||||||
<Col xs={24} xl={8}>
|
<Col xs={24} xl={8}>
|
||||||
<div className="dashboard-user-block">
|
<div className="dashboard-user-block">
|
||||||
|
|
@ -327,7 +327,7 @@ const Dashboard = ({ user }) => {
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</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-toolbar">
|
||||||
<div className="dashboard-search-row">
|
<div className="dashboard-search-row">
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const EditKnowledgeBase = ({ user }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="edit-kb-modern" style={{ maxWidth: 1000, margin: '0 auto', padding: '24px 0' }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
|
||||||
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
||||||
<Title level={3} style={{ margin: 0 }}>编辑知识库条目</Title>
|
<Title level={3} style={{ margin: 0 }}>编辑知识库条目</Title>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
const EditMeeting = ({ user }) => {
|
const EditMeeting = () => {
|
||||||
const { meeting_id } = useParams();
|
const { meeting_id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
@ -44,7 +44,7 @@ const EditMeeting = ({ user }) => {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...meeting,
|
...meeting,
|
||||||
meeting_time: dayjs(meeting.meeting_time),
|
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) || []
|
tags: meeting.tags?.map(t => t.name) || []
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -60,14 +60,14 @@ const EditMeeting = ({ user }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
|
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(',') || ''
|
tags: values.tags?.join(',') || ''
|
||||||
};
|
};
|
||||||
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload);
|
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload);
|
||||||
message.success('会议更新成功');
|
message.success('会议更新成功');
|
||||||
navigate(`/meetings/${meeting_id}`);
|
navigate(`/meetings/${meeting_id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('更新失败');
|
message.error(error.response?.data?.message || error.response?.data?.detail || '更新失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ const EditMeeting = ({ user }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="edit-meeting-modern" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
|
||||||
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
<Button icon={<ArrowLeftOutlined />} shape="circle" onClick={() => navigate(-1)} style={{ marginRight: 16 }} />
|
||||||
<Title level={3} style={{ margin: 0 }}>编辑会议信息</Title>
|
<Title level={3} style={{ margin: 0 }}>编辑会议信息</Title>
|
||||||
|
|
@ -101,9 +101,9 @@ const EditMeeting = ({ user }) => {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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="选择参会人">
|
<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>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Row, Col, Typography, Button,
|
Row, Col, Typography, Button,
|
||||||
Form, Input, Space, Tabs, App
|
Form, Input, Space, Tabs, App
|
||||||
|
|
@ -12,13 +12,19 @@ import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import menuService from '../services/menuService';
|
import menuService from '../services/menuService';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
||||||
|
|
||||||
const { Title, Paragraph, Text } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
const HomePage = ({ onLogin }) => {
|
const HomePage = ({ onLogin }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (values) => {
|
const handleLogin = async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,16 +69,15 @@ const HomePage = ({ onLogin }) => {
|
||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ marginBottom: 80 }}>
|
<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>
|
||||||
|
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
<Title style={{ fontSize: 56, marginBottom: 24, fontWeight: 800 }}>
|
<Title style={{ fontSize: 56, marginBottom: 24, fontWeight: 800 }}>
|
||||||
智能协作 <span style={{ color: '#1677ff' }}>会议管理平台</span>
|
<span style={{ color: '#1677ff' }}>{branding.home_headline}</span>
|
||||||
</Title>
|
</Title>
|
||||||
<Paragraph style={{ fontSize: 20, color: '#4e5969', lineHeight: 1.6 }}>
|
<Paragraph style={{ fontSize: 20, color: '#4e5969', lineHeight: 1.6 }}>
|
||||||
全流程会议辅助,让每一份交流都产生价值。
|
{branding.home_tagline}
|
||||||
实时转录、自动总结、知识沉淀。
|
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -100,7 +105,7 @@ const HomePage = ({ onLogin }) => {
|
||||||
|
|
||||||
<div style={{ marginBottom: 40 }}>
|
<div style={{ marginBottom: 40 }}>
|
||||||
<Paragraph type="secondary" style={{ fontSize: 16 }}>
|
<Paragraph type="secondary" style={{ fontSize: 16 }}>
|
||||||
欢迎回来,请输入您的登录凭证。
|
{branding.login_welcome}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -150,7 +155,7 @@ const HomePage = ({ onLogin }) => {
|
||||||
|
|
||||||
<div style={{ marginTop: 100, textAlign: 'center' }}>
|
<div style={{ marginTop: 100, textAlign: 'center' }}>
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
©2026 iMeeting · 智能会议协作平台
|
{branding.footer_text}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -173,13 +173,13 @@ const MeetingCenterPage = ({ user }) => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
marginBottom: 22,
|
marginBottom: 22,
|
||||||
boxShadow: '0 8px 30px rgba(40, 72, 120, 0.08)',
|
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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Space size={14} align="center">
|
<Space size={14} align="center">
|
||||||
|
|
@ -252,7 +252,7 @@ const MeetingCenterPage = ({ user }) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={meeting.meeting_id}
|
key={meeting.meeting_id}
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
hoverable
|
hoverable
|
||||||
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
|
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -263,7 +263,7 @@ const MeetingCenterPage = ({ user }) => {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: 0 }}
|
styles={{ body: { padding: 0 } }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
|
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
|
||||||
Tooltip, Progress, Spin, App, Dropdown,
|
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';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined, UserOutlined,
|
ClockCircleOutlined, UserOutlined,
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
EyeOutlined, FileTextOutlined, PartitionOutlined,
|
EyeOutlined, FileTextOutlined, PartitionOutlined,
|
||||||
SaveOutlined, CloseOutlined,
|
SaveOutlined, CloseOutlined,
|
||||||
StarFilled, RobotOutlined, DownloadOutlined,
|
StarFilled, RobotOutlined, DownloadOutlined,
|
||||||
DownOutlined,
|
DownOutlined, CheckOutlined,
|
||||||
MoreOutlined, AudioOutlined
|
MoreOutlined, AudioOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||||
|
|
@ -65,6 +65,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
||||||
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
||||||
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
||||||
|
const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null);
|
||||||
const [llmModels, setLlmModels] = useState([]);
|
const [llmModels, setLlmModels] = useState([]);
|
||||||
const [selectedModelCode, setSelectedModelCode] = useState(null);
|
const [selectedModelCode, setSelectedModelCode] = useState(null);
|
||||||
|
|
||||||
|
|
@ -75,6 +76,9 @@ const MeetingDetails = ({ user }) => {
|
||||||
const [showQRModal, setShowQRModal] = useState(false);
|
const [showQRModal, setShowQRModal] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
|
||||||
|
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
|
||||||
|
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
|
||||||
|
|
||||||
// 转录编辑 Drawer
|
// 转录编辑 Drawer
|
||||||
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
|
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
|
||||||
|
|
@ -84,9 +88,26 @@ const MeetingDetails = ({ user }) => {
|
||||||
// 总结内容编辑(同窗口)
|
// 总结内容编辑(同窗口)
|
||||||
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
||||||
const [editingSummaryContent, setEditingSummaryContent] = useState('');
|
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 audioRef = useRef(null);
|
||||||
const transcriptRefs = useRef([]);
|
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);
|
setLoading(true);
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
||||||
setMeeting(response.data);
|
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) {
|
if (response.data.transcription_status) {
|
||||||
const ts = response.data.transcription_status;
|
const ts = response.data.transcription_status;
|
||||||
|
|
@ -113,6 +139,17 @@ const MeetingDetails = ({ user }) => {
|
||||||
if (['pending', 'processing'].includes(ts.status)) {
|
if (['pending', 'processing'].includes(ts.status)) {
|
||||||
startStatusPolling(ts.task_id);
|
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 {
|
try {
|
||||||
|
|
@ -121,6 +158,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
} catch { setAudioUrl(null); }
|
} catch { setAudioUrl(null); }
|
||||||
|
|
||||||
fetchTranscript();
|
fetchTranscript();
|
||||||
|
fetchSummaryHistory();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载会议详情失败');
|
message.error('加载会议详情失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -154,7 +192,13 @@ const MeetingDetails = ({ user }) => {
|
||||||
setTranscriptionProgress(status.progress || 0);
|
setTranscriptionProgress(status.progress || 0);
|
||||||
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
|
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
if (status.status === 'completed') fetchTranscript();
|
if (status.status === 'completed') {
|
||||||
|
fetchTranscript();
|
||||||
|
fetchMeetingDetails();
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchSummaryHistory();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { clearInterval(interval); }
|
} catch { clearInterval(interval); }
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
@ -173,17 +217,73 @@ const MeetingDetails = ({ user }) => {
|
||||||
const fetchLlmModels = async () => {
|
const fetchLlmModels = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS));
|
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);
|
setLlmModels(models);
|
||||||
const def = models.find(m => m.is_default);
|
const def = models.find(m => m.is_default);
|
||||||
if (def) setSelectedModelCode(def.model_code);
|
if (def) setSelectedModelCode(def.model_code);
|
||||||
} catch {}
|
} 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 () => {
|
const fetchSummaryHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`));
|
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 {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -211,6 +311,12 @@ const MeetingDetails = ({ user }) => {
|
||||||
formData.append('audio_file', file);
|
formData.append('audio_file', file);
|
||||||
formData.append('meeting_id', meeting_id);
|
formData.append('meeting_id', meeting_id);
|
||||||
formData.append('force_replace', 'true');
|
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);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
|
||||||
|
|
@ -220,10 +326,119 @@ const MeetingDetails = ({ user }) => {
|
||||||
finally { setIsUploading(false); }
|
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 = () => {
|
const openAudioUploadPicker = () => {
|
||||||
document.getElementById('audio-upload-input')?.click();
|
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) => {
|
const changePlaybackRate = (nextRate) => {
|
||||||
setPlaybackRate(nextRate);
|
setPlaybackRate(nextRate);
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
|
|
@ -258,6 +473,18 @@ const MeetingDetails = ({ user }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateSummary = async () => {
|
const generateSummary = async () => {
|
||||||
|
if (isUploading) {
|
||||||
|
message.warning('音频上传中,暂不允许重新总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasUploadedAudio) {
|
||||||
|
message.warning('请先上传音频后再总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTranscriptionRunning) {
|
||||||
|
message.warning('转录进行中,暂不允许重新总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSummaryLoading(true);
|
setSummaryLoading(true);
|
||||||
setSummaryTaskProgress(0);
|
setSummaryTaskProgress(0);
|
||||||
try {
|
try {
|
||||||
|
|
@ -266,29 +493,26 @@ const MeetingDetails = ({ user }) => {
|
||||||
prompt_id: selectedPromptId,
|
prompt_id: selectedPromptId,
|
||||||
model_code: selectedModelCode
|
model_code: selectedModelCode
|
||||||
});
|
});
|
||||||
const taskId = res.data.task_id;
|
startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true });
|
||||||
const interval = setInterval(async () => {
|
} catch (error) {
|
||||||
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
|
message.error(error?.response?.data?.message || '生成总结失败');
|
||||||
const s = statusRes.data;
|
setSummaryLoading(false);
|
||||||
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); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSummaryDrawer = () => {
|
const openSummaryDrawer = () => {
|
||||||
|
if (isUploading) {
|
||||||
|
message.warning('音频上传中,暂不允许重新总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasUploadedAudio) {
|
||||||
|
message.warning('请先上传音频后再总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTranscriptionRunning) {
|
||||||
|
message.warning('转录进行中,完成后会自动总结');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowSummaryDrawer(true);
|
setShowSummaryDrawer(true);
|
||||||
fetchSummaryHistory();
|
fetchSummaryHistory();
|
||||||
};
|
};
|
||||||
|
|
@ -374,7 +598,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
<div>
|
<div>
|
||||||
{/* ── 标题 Header ── */}
|
{/* ── 标题 Header ── */}
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
className="console-surface"
|
className="console-surface"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
styles={{ body: { padding: '16px 24px' } }}
|
||||||
|
|
@ -404,7 +628,13 @@ const MeetingDetails = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space style={{ flexShrink: 0 }}>
|
<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>
|
<Button icon={<DownloadOutlined />} onClick={downloadSummaryMd}>下载总结</Button>
|
||||||
<Tooltip title="分享二维码">
|
<Tooltip title="分享二维码">
|
||||||
<Button icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
|
<Button icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
|
||||||
|
|
@ -418,7 +648,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
|
|
||||||
{/* ── 转录进度条 ── */}
|
{/* ── 转录进度条 ── */}
|
||||||
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
|
{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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
|
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
|
||||||
<Text>{transcriptionProgress}%</Text>
|
<Text>{transcriptionProgress}%</Text>
|
||||||
|
|
@ -442,7 +672,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
{/* 左列: 语音转录 */}
|
{/* 左列: 语音转录 */}
|
||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
className="console-surface"
|
className="console-surface"
|
||||||
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
|
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
|
||||||
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: 0 } }}
|
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: 0 } }}
|
||||||
|
|
@ -534,16 +764,78 @@ const MeetingDetails = ({ user }) => {
|
||||||
icon={<UserOutlined />}
|
icon={<UserOutlined />}
|
||||||
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
|
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
|
||||||
strong
|
<Space.Compact onClick={(e) => e.stopPropagation()}>
|
||||||
style={{ color: '#1677ff', cursor: 'pointer', fontSize: 13 }}
|
<Input
|
||||||
onClick={e => { e.stopPropagation(); openTranscriptEditDrawer(index); }}
|
size="small"
|
||||||
>
|
autoFocus
|
||||||
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
value={inlineSpeakerValue}
|
||||||
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
|
onChange={(e) => setInlineSpeakerValue(e.target.value)}
|
||||||
</Text>
|
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>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -559,7 +851,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
{/* 右列: AI 总结 / 思维导图 */}
|
{/* 右列: AI 总结 / 思维导图 */}
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
className="console-surface"
|
className="console-surface"
|
||||||
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
|
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
|
||||||
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: '12px 0 0' } }}
|
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: '12px 0 0' } }}
|
||||||
|
|
@ -610,7 +902,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
{/* 总结生成中进度条 */}
|
{/* 总结生成中进度条 */}
|
||||||
{summaryLoading && (
|
{summaryLoading && (
|
||||||
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
|
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
|
||||||
<Text>{summaryTaskProgress}%</Text>
|
<Text>{summaryTaskProgress}%</Text>
|
||||||
|
|
@ -735,7 +1027,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
|
|
||||||
{/* 生成进度 */}
|
{/* 生成进度 */}
|
||||||
{summaryLoading && (
|
{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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<Text><SyncOutlined spin style={{ marginRight: 8 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
|
<Text><SyncOutlined spin style={{ marginRight: 8 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
|
||||||
<Text>{summaryTaskProgress}%</Text>
|
<Text>{summaryTaskProgress}%</Text>
|
||||||
|
|
@ -864,7 +1156,48 @@ const MeetingDetails = ({ user }) => {
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</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
|
<MeetingFormDrawer
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
onClose={() => setEditDrawerOpen(false)}
|
onClose={() => setEditDrawerOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -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 { useParams, Link } from 'react-router-dom';
|
||||||
import {
|
import { Layout, Space, Button, App, Tag, Empty, Input, Tabs } from 'antd';
|
||||||
Layout, Card, Typography, Space, Button,
|
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, UserOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
|
||||||
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 apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import MindMap from '../components/MindMap';
|
||||||
import tools from '../utils/tools';
|
import tools from '../utils/tools';
|
||||||
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
||||||
|
import './MeetingPreview.css';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Content } = Layout;
|
||||||
const { Title, Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const MeetingPreview = () => {
|
const MeetingPreview = () => {
|
||||||
const { meeting_id } = useParams();
|
const { meeting_id } = useParams();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const audioRef = useRef(null);
|
||||||
const [meeting, setMeeting] = useState(null);
|
const [meeting, setMeeting] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
const [passwordRequired, setPasswordRequired] = useState(false);
|
||||||
const [isAuthorized, setIsAuthorized] = 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(() => {
|
useEffect(() => {
|
||||||
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMeeting(null);
|
||||||
|
setTranscript([]);
|
||||||
|
setAudioUrl('');
|
||||||
|
setActiveSegmentIndex(-1);
|
||||||
|
setError(null);
|
||||||
|
setPassword('');
|
||||||
|
setPasswordError('');
|
||||||
|
setPasswordRequired(false);
|
||||||
|
setIsAuthorized(false);
|
||||||
fetchPreview();
|
fetchPreview();
|
||||||
}, [meeting_id]);
|
}, [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);
|
setLoading(true);
|
||||||
try {
|
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);
|
const res = await apiClient.get(url);
|
||||||
setMeeting(res.data);
|
setMeeting(res.data);
|
||||||
setIsAuthorized(true);
|
setIsAuthorized(true);
|
||||||
|
setPasswordRequired(false);
|
||||||
|
setPasswordError('');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
await fetchTranscriptAndAudio();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 401) {
|
const responseCode = String(err?.response?.data?.code || '');
|
||||||
|
if (responseCode === '401') {
|
||||||
|
setMeeting(null);
|
||||||
setIsAuthorized(false);
|
setIsAuthorized(false);
|
||||||
|
setPasswordRequired(true);
|
||||||
|
setPasswordError(err?.response?.data?.message || '');
|
||||||
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
setError('无法加载会议预览');
|
setMeeting(null);
|
||||||
|
setIsAuthorized(false);
|
||||||
|
setPasswordRequired(false);
|
||||||
|
setPasswordError('');
|
||||||
|
setError(err?.response?.data?.message || '无法加载会议预览');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 = () => {
|
const handleVerify = () => {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
message.warning('请输入访问密码');
|
message.warning('请输入访问密码');
|
||||||
|
|
@ -59,62 +138,191 @@ const MeetingPreview = () => {
|
||||||
fetchPreview(password);
|
fetchPreview(password);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && !meeting) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" /></div>;
|
if (loading && !meeting) {
|
||||||
|
|
||||||
if (!isAuthorized) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
<div className="preview-container">
|
||||||
<Card style={{ width: 400, borderRadius: 16, textAlign: 'center', boxShadow: '0 8px 24px rgba(0,0,0,0.05)' }}>
|
<div className="preview-loading">
|
||||||
<LockOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
|
<div className="loading-spinner" />
|
||||||
<Title level={3}>此会议受密码保护</Title>
|
<p>正在加载会议预览...</p>
|
||||||
<Paragraph type="secondary">请输入访问密码以查看会议纪要</Paragraph>
|
</div>
|
||||||
<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>
|
</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 (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh', background: '#f0f2f5' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
<Header style={{ background: '#fff', padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}>
|
<Content className="preview-container">
|
||||||
<Space>
|
<div className="preview-content">
|
||||||
<BrandLogo title="iMeeting 会议预览" size={28} titleSize={18} gap={10} />
|
<h1 className="preview-title">{meeting.title}</h1>
|
||||||
</Space>
|
|
||||||
<Link to="/"><Button icon={<LoginOutlined />}>登录系统</Button></Link>
|
<div className="meeting-info-section">
|
||||||
</Header>
|
<h2 className="section-title">会议信息</h2>
|
||||||
|
<div className="info-item"><strong>创建人:</strong>{meeting.creator_username || '未知'}</div>
|
||||||
<Content style={{ padding: '40px 20px', maxWidth: 900, margin: '0 auto', width: '100%' }}>
|
<div className="info-item"><strong>会议时间:</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
|
||||||
<Card bordered={false} style={{ borderRadius: 16 }}>
|
<div className="info-item"><strong>参会人员:</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
|
||||||
<Title level={2}>{meeting.title}</Title>
|
<div className="info-item"><strong>计算人数:</strong>{meeting.attendees_count || meeting.attendees?.length || 0}人</div>
|
||||||
<Space wrap style={{ marginBottom: 20 }}>
|
<div className="info-item"><strong>总结模板:</strong>{meeting.prompt_name || '默认模板'}</div>
|
||||||
{meeting.tags?.map(t => <Tag key={t.id} color="blue">{t.name}</Tag>)}
|
{meeting.tags?.length ? (
|
||||||
<Text type="secondary"><CalendarOutlined /> {tools.formatDateTime(meeting.meeting_time)}</Text>
|
<div className="info-item">
|
||||||
</Space>
|
<strong>标签:</strong>
|
||||||
|
<Space wrap style={{ marginLeft: 8 }}>
|
||||||
<Divider />
|
{meeting.tags.map((tag) => <Tag key={tag.id || tag.name} color="blue">{tag.name}</Tag>)}
|
||||||
|
</Space>
|
||||||
<div className="preview-content">
|
</div>
|
||||||
{meeting.summary ? (
|
) : null}
|
||||||
<MarkdownRenderer content={meeting.summary} />
|
|
||||||
) : (
|
|
||||||
<Empty description="暂无会议总结内容" />
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ const PromptConfigPage = ({ user }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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' }}>
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ margin: 0 }}>提示词配置</Title>
|
<Title level={3} style={{ margin: 0 }}>提示词配置</Title>
|
||||||
|
|
@ -117,7 +117,7 @@ const PromptConfigPage = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="console-surface" bordered={false} bodyStyle={{ padding: 12 }}>
|
<Card className="console-surface" variant="borderless" styles={{ body: { padding: 12 } }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
className="console-tabs"
|
className="console-tabs"
|
||||||
defaultActiveKey="config"
|
defaultActiveKey="config"
|
||||||
|
|
@ -125,7 +125,7 @@ const PromptConfigPage = ({ user }) => {
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'config',
|
key: 'config',
|
||||||
label: '提示词配置',
|
label: '系统提示词配置',
|
||||||
children: (
|
children: (
|
||||||
<div className="console-tab-panel">
|
<div className="console-tab-panel">
|
||||||
<div className="console-tab-toolbar">
|
<div className="console-tab-toolbar">
|
||||||
|
|
@ -142,7 +142,7 @@ const PromptConfigPage = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} xl={14}>
|
<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%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{availablePrompts.length ? availablePrompts.map((item) => {
|
{availablePrompts.length ? availablePrompts.map((item) => {
|
||||||
const isSystem = Number(item.is_system) === 1;
|
const isSystem = Number(item.is_system) === 1;
|
||||||
|
|
@ -188,7 +188,7 @@ const PromptConfigPage = ({ user }) => {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} xl={10}>
|
<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%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{selectedPromptCards.length ? selectedPromptCards.map((item, index) => (
|
{selectedPromptCards.length ? selectedPromptCards.map((item, index) => (
|
||||||
<Card key={item.id} size="small" style={{ borderRadius: 10 }}>
|
<Card key={item.id} size="small" style={{ borderRadius: 10 }}>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Segmented,
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -240,7 +241,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!embedded ? (
|
{!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' }}>
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ margin: 0 }}>{pageTitle}</Title>
|
<Title level={3} style={{ margin: 0 }}>{pageTitle}</Title>
|
||||||
|
|
@ -333,7 +334,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="console-surface" bordered={false}>
|
<Card className="console-surface" variant="borderless">
|
||||||
{prompts.length ? (
|
{prompts.length ? (
|
||||||
<>
|
<>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ const DictManagement = () => {
|
||||||
新增
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -300,13 +300,14 @@ const DictManagement = () => {
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
bordered={false}
|
variant="borderless"
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
<Form
|
||||||
<Form
|
form={form}
|
||||||
form={form}
|
layout="vertical"
|
||||||
layout="vertical"
|
>
|
||||||
>
|
{isEditing ? (
|
||||||
|
<>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
@ -385,13 +386,14 @@ const DictManagement = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty
|
<Empty
|
||||||
description="请从左侧树中选择一个节点进行编辑,或点击新增按钮创建新节点"
|
description="请从左侧树中选择一个节点进行编辑,或点击新增按钮创建新节点"
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
|
Tag,
|
||||||
Tabs,
|
Tabs,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
|
|
|
||||||
|
|
@ -675,7 +675,7 @@ const PermissionManagement = () => {
|
||||||
const roleManagementView = (
|
const roleManagementView = (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={7}>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<Title level={5} style={{ margin: 0 }}>角色列表</Title>
|
<Title level={5} style={{ margin: 0 }}>角色列表</Title>
|
||||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={openCreateRoleDrawer}>新增</Button>
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={openCreateRoleDrawer}>新增</Button>
|
||||||
|
|
@ -730,7 +730,7 @@ const PermissionManagement = () => {
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col span={17}>
|
<Col span={17}>
|
||||||
<Card className="console-surface" bodyStyle={{ padding: 12 }}>
|
<Card className="console-surface" styles={{ body: { padding: 12 } }}>
|
||||||
{!selectedRoleId ? (
|
{!selectedRoleId ? (
|
||||||
<Empty description="请选择左侧角色" />
|
<Empty description="请选择左侧角色" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -763,7 +763,7 @@ const PermissionManagement = () => {
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={14}>
|
<Row gutter={14}>
|
||||||
<Col span={14}>
|
<Col span={14}>
|
||||||
<Card size="small" bodyStyle={{ padding: 12, minHeight: 420 }}>
|
<Card size="small" styles={{ body: { padding: 12, minHeight: 420 } }}>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索菜单名称或编码"
|
placeholder="搜索菜单名称或编码"
|
||||||
|
|
@ -806,7 +806,7 @@ const PermissionManagement = () => {
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
title={`已选权限 (${selectedMenuRows.length})`}
|
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) => (
|
{selectedMenuRows.length ? selectedMenuRows.map((menu) => (
|
||||||
<div key={menu.menu_id} style={{ padding: '6px 0' }}>
|
<div key={menu.menu_id} style={{ padding: '6px 0' }}>
|
||||||
|
|
@ -864,7 +864,7 @@ const PermissionManagement = () => {
|
||||||
const menuManagementView = (
|
const menuManagementView = (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={10}>
|
<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' }}>
|
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
|
|
@ -918,7 +918,7 @@ const PermissionManagement = () => {
|
||||||
<Col span={14}>
|
<Col span={14}>
|
||||||
<Card
|
<Card
|
||||||
className="console-surface"
|
className="console-surface"
|
||||||
bodyStyle={{ padding: 16, minHeight: 640 }}
|
styles={{ body: { padding: 16, minHeight: 640 } }}
|
||||||
title={
|
title={
|
||||||
menuPanelMode === 'view' && selectedManageMenu
|
menuPanelMode === 'view' && selectedManageMenu
|
||||||
? `${MENU_PANEL_TITLE_MAP[menuPanelMode]} · ${selectedManageMenu.menu_name}`
|
? `${MENU_PANEL_TITLE_MAP[menuPanelMode]} · ${selectedManageMenu.menu_name}`
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
import apiClient from './apiClient';
|
import apiClient from './apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
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 {
|
class ConfigService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configs = null;
|
this.configs = null;
|
||||||
this.loadPromise = null;
|
this.loadPromise = null;
|
||||||
this._pageSize = null;
|
this._pageSize = null;
|
||||||
|
this.brandingConfig = null;
|
||||||
|
this.brandingPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfigs() {
|
async getConfigs() {
|
||||||
|
|
@ -64,6 +76,33 @@ class ConfigService {
|
||||||
return 10;
|
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) {
|
formatFileSize(bytes) {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
@ -78,6 +117,8 @@ class ConfigService {
|
||||||
this.configs = null;
|
this.configs = null;
|
||||||
this.loadPromise = null;
|
this.loadPromise = null;
|
||||||
this._pageSize = null;
|
this._pageSize = null;
|
||||||
|
this.brandingConfig = null;
|
||||||
|
this.brandingPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue