diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index bf07671..1abc4dc 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -12,10 +12,11 @@ from app.services.system_config_service import SystemConfigService from app.utils.audio_parser import get_audio_duration from app.core.auth import get_current_user, get_optional_current_user from app.core.response import create_api_response -from typing import List, Optional +from typing import Any, Dict, List, Optional from datetime import datetime from pydantic import BaseModel from urllib.parse import quote +from collections import defaultdict import os import uuid import shutil @@ -32,13 +33,17 @@ class GenerateSummaryRequest(BaseModel): model_code: Optional[str] = None # LLM模型编码,如果不指定则使用默认模型 +def _split_tag_names(tag_string: Optional[str]) -> List[str]: + if not tag_string: + return [] + return [name.strip() for name in str(tag_string).split(',') if name and name.strip()] + + def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: """ 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 """ - if not tag_string: - return [] - tag_names = [name.strip() for name in tag_string.split(',') if name.strip()] + tag_names = _split_tag_names(tag_string) if not tag_names: return [] @@ -66,6 +71,209 @@ def _sync_attendees(cursor, meeting_id: int, attendee_ids: Optional[List[int]]) [(meeting_id, user_id) for user_id in attendee_id_list] ) + +def _serialize_task_record(task_record: Optional[Dict[str, Any]], meeting_id: int) -> Optional[Dict[str, Any]]: + if not task_record: + return None + + created_at = task_record.get('created_at') + completed_at = task_record.get('completed_at') + + return { + 'task_id': task_record.get('task_id'), + 'status': task_record.get('status', 'pending') or 'pending', + 'progress': int(task_record.get('progress') or 0), + 'meeting_id': meeting_id, + 'created_at': created_at.isoformat() if hasattr(created_at, 'isoformat') else created_at, + 'completed_at': completed_at.isoformat() if hasattr(completed_at, 'isoformat') else completed_at, + 'error_message': task_record.get('error_message') + } + + +def _build_meeting_overall_status( + transcription_status: Optional[Dict[str, Any]] = None, + llm_status: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + trans_data = { + "status": transcription_status.get('status', 'pending') if transcription_status else 'pending', + "progress": transcription_status.get('progress', 0) if transcription_status else 0, + "task_id": transcription_status.get('task_id') if transcription_status else None, + "error_message": transcription_status.get('error_message') if transcription_status else None, + "created_at": transcription_status.get('created_at') if transcription_status else None + } + + llm_data = { + "status": llm_status.get('status', 'pending') if llm_status else 'pending', + "progress": llm_status.get('progress', 0) if llm_status else 0, + "task_id": llm_status.get('task_id') if llm_status else None, + "error_message": llm_status.get('error_message') if llm_status else None, + "created_at": llm_status.get('created_at') if llm_status else None + } + + trans_status_val = trans_data["status"] + llm_status_val = llm_data["status"] + + if trans_status_val == 'failed': + overall_status = "failed" + current_stage = "transcription" + overall_progress = 0 + elif llm_status_val == 'failed': + overall_status = "failed" + current_stage = "llm" + overall_progress = 50 + elif trans_status_val == 'completed' and llm_status_val == 'completed': + overall_status = "completed" + current_stage = "completed" + overall_progress = 100 + elif trans_status_val == 'completed': + if llm_status_val in ['pending', 'processing']: + overall_status = "summarizing" + current_stage = "llm" + overall_progress = 50 + int(llm_data["progress"] * 0.5) + else: + overall_status = "summarizing" + current_stage = "llm" + overall_progress = 50 + else: + if trans_status_val in ['pending', 'processing']: + overall_status = "transcribing" + current_stage = "transcription" + overall_progress = int(trans_data["progress"] * 0.5) + else: + overall_status = "pending" + current_stage = "transcription" + overall_progress = 0 + + return { + "overall_status": overall_status, + "overall_progress": overall_progress, + "current_stage": current_stage, + "transcription": trans_data, + "llm": llm_data + } + + +def _load_attendees_map(cursor, meeting_ids: List[int]) -> Dict[int, List[Dict[str, Any]]]: + if not meeting_ids: + return {} + + format_strings = ', '.join(['%s'] * len(meeting_ids)) + query = f''' + SELECT a.meeting_id, u.user_id, u.caption + FROM attendees a + JOIN sys_users u ON a.user_id = u.user_id + WHERE a.meeting_id IN ({format_strings}) + ORDER BY a.meeting_id ASC, a.attendee_id ASC + ''' + cursor.execute(query, tuple(meeting_ids)) + + attendees_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + for row in cursor.fetchall(): + attendees_map[row['meeting_id']].append({ + 'user_id': row['user_id'], + 'caption': row['caption'] + }) + + return dict(attendees_map) + + +def _load_tags_map(cursor, meetings: List[Dict[str, Any]]) -> Dict[int, List[Tag]]: + meeting_tag_names: Dict[int, List[str]] = {} + all_tag_names: List[str] = [] + + for meeting in meetings: + tag_names = _split_tag_names(meeting.get('tags')) + meeting_tag_names[meeting['meeting_id']] = tag_names + all_tag_names.extend(tag_names) + + if not all_tag_names: + return {} + + unique_tag_names = list(dict.fromkeys(all_tag_names)) + format_strings = ', '.join(['%s'] * len(unique_tag_names)) + cursor.execute( + f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", + tuple(unique_tag_names) + ) + name_to_tag = {row['name']: Tag(**row) for row in cursor.fetchall()} + + return { + meeting_id: [name_to_tag[name] for name in tag_names if name in name_to_tag] + for meeting_id, tag_names in meeting_tag_names.items() + } + + +def _load_latest_task_map(cursor, meeting_ids: List[int], task_type: str) -> Dict[int, Dict[str, Any]]: + if not meeting_ids: + return {} + + table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' + format_strings = ', '.join(['%s'] * len(meeting_ids)) + query = f''' + SELECT meeting_id, task_id, status, progress, created_at, completed_at, error_message + FROM {table_name} + WHERE meeting_id IN ({format_strings}) + ORDER BY meeting_id ASC, created_at DESC, task_id DESC + ''' + cursor.execute(query, tuple(meeting_ids)) + + latest_task_map: Dict[int, Dict[str, Any]] = {} + for row in cursor.fetchall(): + meeting_id = row['meeting_id'] + if meeting_id not in latest_task_map: + latest_task_map[meeting_id] = _serialize_task_record(row, meeting_id) + + return latest_task_map + + +def _load_latest_task_record(cursor, meeting_id: int, task_type: str) -> Optional[Dict[str, Any]]: + table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' + query = f''' + SELECT task_id, status, progress, created_at, completed_at, error_message + FROM {table_name} + WHERE meeting_id = %s + ORDER BY created_at DESC, task_id DESC + LIMIT 1 + ''' + cursor.execute(query, (meeting_id,)) + return _serialize_task_record(cursor.fetchone(), meeting_id) + + +def _load_overall_status_map(cursor, meeting_ids: List[int]) -> Dict[int, Dict[str, Any]]: + if not meeting_ids: + return {} + + transcription_map = _load_latest_task_map(cursor, meeting_ids, 'transcription') + llm_map = _load_latest_task_map(cursor, meeting_ids, 'llm') + + return { + meeting_id: _build_meeting_overall_status( + transcription_map.get(meeting_id), + llm_map.get(meeting_id) + ) + for meeting_id in meeting_ids + } + + +def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[TranscriptionTaskStatus]: + if not task_record: + return None + + return TranscriptionTaskStatus( + task_id=task_record.get('task_id'), + status=task_record.get('status', 'pending') or 'pending', + progress=int(task_record.get('progress') or 0) + ) + +def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int): + cursor.execute("SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return None, create_api_response(code="404", message="Meeting not found") + if meeting['user_id'] != current_user_id: + return None, create_api_response(code="403", message="仅会议创建人可执行此操作") + return meeting, None + def _get_meeting_overall_status(meeting_id: int) -> dict: """ 获取会议的整体进度状态(包含转译和LLM两个阶段) @@ -79,74 +287,9 @@ def _get_meeting_overall_status(meeting_id: int) -> dict: "llm": {status, progress, task_id, error_message, created_at} } """ - # 获取转译状态 transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) - trans_data = { - "status": transcription_status.get('status', 'pending') if transcription_status else 'pending', - "progress": transcription_status.get('progress', 0) if transcription_status else 0, - "task_id": transcription_status.get('task_id') if transcription_status else None, - "error_message": transcription_status.get('error_message') if transcription_status else None, - "created_at": transcription_status.get('created_at') if transcription_status else None - } - - # 获取LLM状态 llm_status = async_meeting_service.get_meeting_llm_status(meeting_id) - llm_data = { - "status": llm_status.get('status', 'pending') if llm_status else 'pending', - "progress": llm_status.get('progress', 0) if llm_status else 0, - "task_id": llm_status.get('task_id') if llm_status else None, - "error_message": llm_status.get('error_message') if llm_status else None, - "created_at": llm_status.get('created_at') if llm_status else None - } - - # 计算整体状态和进度 - trans_status_val = trans_data["status"] - llm_status_val = llm_data["status"] - - # 判断是否有失败 - if trans_status_val == 'failed': - overall_status = "failed" - current_stage = "transcription" - overall_progress = 0 - elif llm_status_val == 'failed': - overall_status = "failed" - current_stage = "llm" - overall_progress = 50 # 转译已完成 - # 判断当前阶段 - elif trans_status_val == 'completed' and llm_status_val == 'completed': - overall_status = "completed" - current_stage = "completed" - overall_progress = 100 - elif trans_status_val == 'completed': - # 转译完成,进入LLM阶段 - if llm_status_val in ['pending', 'processing']: - overall_status = "summarizing" - current_stage = "llm" - overall_progress = 50 + int(llm_data["progress"] * 0.5) - else: - # llm还未开始 - overall_status = "summarizing" - current_stage = "llm" - overall_progress = 50 - else: - # 还在转译阶段 - if trans_status_val in ['pending', 'processing']: - overall_status = "transcribing" - current_stage = "transcription" - overall_progress = int(trans_data["progress"] * 0.5) - else: - # 转译还未开始 - overall_status = "pending" - current_stage = "transcription" - overall_progress = 0 - - return { - "overall_status": overall_status, - "overall_progress": overall_progress, - "current_stage": current_stage, - "transcription": trans_data, - "llm": llm_data - } + return _build_meeting_overall_status(transcription_status, llm_status) @router.get("/meetings") def get_meetings( @@ -209,7 +352,12 @@ def get_meetings( # 构建基础查询 base_query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, m.access_password, + SELECT m.meeting_id, m.title, m.meeting_time, + CASE + WHEN m.summary IS NULL THEN NULL + ELSE LEFT(m.summary, 240) + END as summary, + m.created_at, m.tags, m.access_password, m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path FROM meetings m JOIN sys_users u ON m.user_id = u.user_id @@ -250,18 +398,20 @@ def get_meetings( cursor.execute(query, params) meetings = cursor.fetchall() + meeting_ids = [meeting['meeting_id'] for meeting in meetings] + attendees_map = _load_attendees_map(cursor, meeting_ids) + tags_map = _load_tags_map(cursor, meetings) + status_map = _load_overall_status_map(cursor, meeting_ids) + meeting_list = [] for meeting in meetings: - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' - cursor.execute(attendees_query, (meeting['meeting_id'],)) - attendees_data = cursor.fetchall() - attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags_list = _process_tags(cursor, meeting.get('tags')) - progress_info = _get_meeting_overall_status(meeting['meeting_id']) + attendees = attendees_map.get(meeting['meeting_id'], []) + tags_list = tags_map.get(meeting['meeting_id'], []) + progress_info = status_map.get(meeting['meeting_id']) or _build_meeting_overall_status() meeting_list.append(Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'], - attendees=attendees, attendee_ids=[row['user_id'] for row in attendees_data], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, + attendees=attendees, attendee_ids=[row['user_id'] for row in attendees], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, access_password=meeting.get('access_password'), overall_status=progress_info.get('overall_status'), overall_progress=progress_info.get('overall_progress'), @@ -345,12 +495,15 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren meeting = cursor.fetchone() if not meeting: return create_api_response(code="404", message="Meeting not found") - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' - cursor.execute(attendees_query, (meeting['meeting_id'],)) - attendees_data = cursor.fetchall() + + attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, []) attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _process_tags(cursor, meeting.get('tags')) + tags = _load_tags_map(cursor, [meeting]).get(meeting_id, []) + transcription_task = _load_latest_task_record(cursor, meeting_id, 'transcription') + llm_task = _load_latest_task_record(cursor, meeting_id, 'llm') + overall_status = _build_meeting_overall_status(transcription_task, llm_task) cursor.close() + meeting_data = Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, @@ -358,24 +511,19 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, prompt_id=meeting.get('prompt_id'), prompt_name=meeting.get('prompt_name'), + overall_status=overall_status.get('overall_status'), + overall_progress=overall_status.get('overall_progress'), + current_stage=overall_status.get('current_stage'), access_password=meeting.get('access_password') ) # 只有路径长度大于5(排除空串或占位符)才认为有录音 if meeting.get('audio_file_path') and len(meeting['audio_file_path']) > 5: meeting_data.audio_file_path = meeting['audio_file_path'] meeting_data.audio_duration = meeting['audio_duration'] - try: - transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id) - if transcription_status_data: - meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) - except Exception as e: - print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") - try: - llm_status_data = async_meeting_service.get_meeting_llm_status(meeting_id) - if llm_status_data: - meeting_data.llm_status = TranscriptionTaskStatus(**llm_status_data) - except Exception as e: - print(f"Warning: Failed to get llm status for meeting {meeting_id}: {e}") + + meeting_data.transcription_status = _build_task_status_model(transcription_task) + meeting_data.llm_status = _build_task_status_model(llm_task) + return create_api_response(code="200", message="获取会议详情成功", data=meeting_data) @router.get("/meetings/{meeting_id}/transcript") @@ -861,7 +1009,10 @@ async def upload_image(meeting_id: int, image_file: UploadFile = File(...), curr def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): try: with get_db_connection() as connection: - cursor = connection.cursor() + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' cursor.execute(update_query, (request.new_tag, meeting_id, request.speaker_id)) if cursor.rowcount == 0: @@ -875,7 +1026,10 @@ def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, curren def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): try: with get_db_connection() as connection: - cursor = connection.cursor() + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response total_updated = 0 for update_item in request.updates: update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' @@ -890,7 +1044,10 @@ def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateReq def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict = Depends(get_current_user)): try: with get_db_connection() as connection: - cursor = connection.cursor() + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response total_updated = 0 for update_item in request.updates: cursor.execute("SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s", (update_item.segment_id, meeting_id)) @@ -936,9 +1093,9 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="Meeting not found") + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response 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={ @@ -1265,6 +1422,12 @@ def update_meeting_access_password( API响应,包含操作结果 """ try: + normalized_password = None + if request.password is not None: + normalized_password = request.password.strip() + if not normalized_password: + return create_api_response(code="400", message="访问密码不能为空") + with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1284,15 +1447,15 @@ def update_meeting_access_password( # 更新访问密码 cursor.execute( "UPDATE meetings SET access_password = %s WHERE meeting_id = %s", - (request.password, meeting_id) + (normalized_password, meeting_id) ) connection.commit() - if request.password: + if normalized_password: return create_api_response( code="200", message="访问密码已设置", - data={"password": request.password} + data={"password": normalized_password} ) else: return create_api_response( diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index b576cee..a8056ce 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -617,8 +617,8 @@ class SystemConfigService: 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 "智能会议控制台"), + "home_tagline": str(cls.get_config(cls.BRANDING_HOME_TAGLINE, "让每一次谈话都产生价值") or "让每一次谈话都产生价值"), + "console_subtitle": str(cls.get_config(cls.BRANDING_CONSOLE_SUBTITLE, "iMeeting控制台") or "iMeeting控制台"), "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 智听云平台"), diff --git a/backend/sql/migrations/create_parameter_and_model_management.sql b/backend/sql/migrations/create_parameter_and_model_management.sql index e222159..30d79d2 100644 --- a/backend/sql/migrations/create_parameter_and_model_management.sql +++ b/backend/sql/migrations/create_parameter_and_model_management.sql @@ -156,11 +156,8 @@ SELECT FROM `sys_menus` m WHERE m.`menu_code` = 'platform_admin'; --- role 1 gets full access -INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`) -SELECT 1, m.menu_id, 1 -FROM sys_menus m -WHERE m.menu_code IN ('parameter_management', 'model_management'); +-- Keep existing role-menu permissions unchanged. +-- New menus are added to sys_menus only; authorization is assigned manually. -- backfill menu tree metadata for newly inserted rows UPDATE `sys_menus` c diff --git a/backend/sql/migrations/expand_menu_permission_tables.sql b/backend/sql/migrations/expand_menu_permission_tables.sql index 7fe267c..d9dabc7 100644 --- a/backend/sql/migrations/expand_menu_permission_tables.sql +++ b/backend/sql/migrations/expand_menu_permission_tables.sql @@ -61,27 +61,8 @@ SET c.`menu_level` = p.`menu_level` + 1, c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`) WHERE c.`tree_path` IS NULL; --- 5) Align permissions: role 1 owns all active menus -INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`) -SELECT 1, m.`menu_id`, 1 -FROM `sys_menus` m -WHERE m.`is_active` = 1; - --- role 2 excludes platform admin tree -DELETE rmp -FROM `sys_role_menu_permissions` rmp -JOIN `sys_menus` m ON m.`menu_id` = rmp.`menu_id` -WHERE rmp.`role_id` = 2 - AND m.`menu_code` IN ( - 'platform_admin', - 'user_management', - 'permission_management', - 'dict_management', - 'hot_word_management', - 'client_management', - 'external_app_management', - 'terminal_management', - 'permission_menu_tree' - ); +-- 5) Keep existing role-menu permissions unchanged. +-- Permission assignment is managed explicitly in the application/admin UI. +-- Re-running this migration must not backfill or overwrite production grants. COMMIT; diff --git a/backend/sql/migrations/optimize_meeting_loading_performance.sql b/backend/sql/migrations/optimize_meeting_loading_performance.sql new file mode 100644 index 0000000..3635597 --- /dev/null +++ b/backend/sql/migrations/optimize_meeting_loading_performance.sql @@ -0,0 +1,81 @@ +-- Migration: optimize meeting loading performance +-- Created at: 2026-04-03 + +BEGIN; + +SET @sql := IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'audio_files' + AND index_name = 'idx_audio_files_meeting_id' + ), + 'SELECT 1', + 'ALTER TABLE `audio_files` ADD KEY `idx_audio_files_meeting_id` (`meeting_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'transcript_segments' + AND index_name = 'idx_transcript_segments_meeting_time' + ), + 'SELECT 1', + 'ALTER TABLE `transcript_segments` ADD KEY `idx_transcript_segments_meeting_time` (`meeting_id`, `start_time_ms`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'transcript_tasks' + AND index_name = 'idx_transcript_tasks_meeting_created' + ), + 'SELECT 1', + 'ALTER TABLE `transcript_tasks` ADD KEY `idx_transcript_tasks_meeting_created` (`meeting_id`, `created_at`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'llm_tasks' + AND index_name = 'idx_llm_tasks_meeting_created' + ), + 'SELECT 1', + 'ALTER TABLE `llm_tasks` ADD KEY `idx_llm_tasks_meeting_created` (`meeting_id`, `created_at`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'meetings' + AND index_name = 'idx_meetings_user_time_created' + ), + 'SELECT 1', + 'ALTER TABLE `meetings` ADD KEY `idx_meetings_user_time_created` (`user_id`, `meeting_time`, `created_at`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +COMMIT; diff --git a/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql b/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql index cd485da..cf63546 100644 --- a/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql +++ b/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql @@ -1203,24 +1203,9 @@ SET c.`menu_level` = p.`menu_level` + 1, -- ---------------------------------------------------------------------- -- 7. Align role-menu permissions for latest menu model -- ---------------------------------------------------------------------- --- Admin gets all active menus -INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`, `granted_at`) -SELECT 1, m.`menu_id`, 1, NOW() -FROM `sys_menus` m -WHERE m.`is_active` = 1; - --- Normal user only gets desktop + meeting menus + prompt config -DELETE p -FROM `sys_role_menu_permissions` p -JOIN `sys_menus` m ON m.`menu_id` = p.`menu_id` -WHERE p.`role_id` = 2 - AND m.`menu_code` NOT IN ('desktop', 'meeting_manage', 'meeting_center', 'prompt_config'); - -INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`, `granted_at`) -SELECT 2, m.`menu_id`, 1, NOW() -FROM `sys_menus` m -WHERE m.`menu_code` IN ('desktop', 'meeting_manage', 'meeting_center', 'prompt_config') - AND m.`is_active` = 1; +-- Keep existing role-menu permissions unchanged. +-- The upgrade must preserve current grants instead of rebuilding defaults, +-- so repeated execution will not auto-complete or reset menu permissions. -- ---------------------------------------------------------------------- -- 8. Recreate compatibility views diff --git a/backend/sql/migrations/upgrade_prompt_library_and_config.sql b/backend/sql/migrations/upgrade_prompt_library_and_config.sql index bf92d5f..854a434 100644 --- a/backend/sql/migrations/upgrade_prompt_library_and_config.sql +++ b/backend/sql/migrations/upgrade_prompt_library_and_config.sql @@ -97,17 +97,7 @@ SET c.menu_level = p.menu_level + 1, c.tree_path = CONCAT(p.tree_path, '/', c.menu_id) WHERE c.menu_code = 'prompt_config'; --- permissions: --- admin gets both -INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id) -SELECT 1, m.menu_id -FROM sys_menus m -WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config'); - --- normal user gets platform_admin + prompt_management + prompt_config -INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id) -SELECT 2, m.menu_id -FROM sys_menus m -WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config'); +-- Keep existing role-menu permissions unchanged. +-- This migration only creates/adjusts menu definitions. COMMIT; diff --git a/design/button-system-guidelines.md b/design/button-system-guidelines.md new file mode 100644 index 0000000..00952da --- /dev/null +++ b/design/button-system-guidelines.md @@ -0,0 +1,222 @@ +# iMeeting 按钮统一规范 + +## 目标 + +统一平台内业务操作按钮的视觉语言、尺寸层级和使用方式,避免同一场景中出现不同形态的编辑、删除、查看、复制等操作按钮。 + +当前平台按钮分为两套标准: + +1. 业务操作按钮:统一通过 [ActionButton](/Users/jiliu/工作/projects/imeeting/frontend/src/components/ActionButton.jsx) 承载。 +2. 表单操作条按钮:统一使用 Ant Design `Button` 家族,不与 `ActionButton` 混用。 + +## 设计原则 + +1. 语义和尺寸分离。 +语义只表达动作类型,例如查看、编辑、删除;尺寸只表达场景层级,例如列表操作、面板次级操作。 + +2. 同一场景只选一种尺寸档。 +不要在同一条记录、同一个卡片头部、同一个面板操作区里混用大图标、小图标、大文字、小文字。 + +3. 删除按钮只保留颜色差异。 +删除按钮和其它按钮使用同一套圆角、边框、阴影和尺寸,不再使用额外的危险态造型。 + +4. 业务操作、表单操作、功能控件分开。 +- 业务操作统一走 `ActionButton` +- 表单操作条统一走标准 `Button` +- 编辑器工具栏、播放器控制等功能控件不强行复用这套规范 + +5. 同一操作条内外框语言必须一致。 +如果一组按钮里已经采用普通 `Button`,就不要混入胶囊式 `ActionButton`;如果一组按钮采用 `ActionButton`,就不要再塞入默认 `Button`。 + +## 标准尺寸 + +### 1. 带文字(大) + +- 组件参数:`variant="textLg"` +- 典型场景:面板内次级操作、详情弹窗中的补充动作、信息面板中的“下载结果 / 复制链接 / 编辑当前菜单” +- 示例:`编辑`、`删除`、`下载转录结果 (JSON)`、`复制链接` + +### 2. 带文字(小) + +- 组件参数:`variant="textSm"` +- 典型场景:卡片头部轻量动作、模块内局部切换动作 +- 示例:`标签`、`编辑`、`查看详情`、`清空已选` + +### 3. 仅图标(大) + +- 组件参数:`variant="iconLg"` +- 典型场景:输入框组合控件右侧操作、需要更高点击面的紧凑操作 +- 示例:分享密码区的 `复制 / 保存` + +### 4. 仅图标(小) + +- 组件参数:`variant="iconSm"` +- 典型场景:列表行操作、卡片角落操作、悬浮于内容边缘的轻量动作 +- 示例:`编辑 / 删除 / 查看 / 同步 / 更多` + +## 标准语义 + +### `tone="view"` + +- 用于查看、打开、复制、下载、预览 +- 颜色语义:蓝色系 + +### `tone="neutral"` + +- 用于更多、清空、重置筛选、非强调型辅助动作 +- 颜色语义:中性色 + +### `tone="edit"` + +- 用于编辑、设置、修改 +- 颜色语义:蓝色系,与查看同体系但文义偏编辑 + +### `tone="accent"` + +- 用于需要强调但非危险的特殊动作 +- 颜色语义:强调色 +- 当前典型场景:`重置密码` + +### `tone="delete"` + +- 用于删除、移除、关闭会话等不可逆动作 +- 颜色语义:红色系 + +## 场景映射 + +### 列表行操作 + +- 一律使用 `iconSm` +- 常见组合:`编辑 / 删除`、`查看 / 编辑 / 删除` +- 示例页面: + - 热词管理 + - 用户管理 + - 参数管理 + - 模型管理 + - 客户端管理 + - 外部应用管理 + +### 卡片角落操作 + +- 一律使用 `iconSm` +- 常见组合:`编辑 / 删除`、`更多` +- 示例页面: + - 会议中心卡片 + - 时间线卡片 + - 知识库左侧列表 + +### 卡片或模块头部的轻量动作 + +- 一律使用 `textSm` +- 常见组合:`标签`、`编辑`、`查看详情` + +### 面板或 Drawer 的次级操作 + +- 一律使用 `textLg` +- 常见组合:`编辑`、`删除`、`复制链接`、`下载转录结果` + +### 表单操作条 + +- 一律使用标准 `Button` +- 常见组合:`删除 / 取消 / 保存` +- 视觉规范: + - `删除`:`className="btn-soft-red"` + - `取消`:默认 `Button` + - `保存`:`type="primary"` +- 说明: + - 这类按钮属于提交与撤销控制,不使用 `ActionButton` + - 同一条内不要出现 `ActionButton tone="delete"` 与普通 `Button` 并排 + +### 输入框附属操作 + +- 一律使用 `iconLg` +- 常见组合:输入框后缀的 `复制 / 保存` + +## 禁止项 + +1. 不要在业务操作中直接裸用 `EditOutlined`、`DeleteOutlined` 作为可点击入口。 + +2. 不要在同一场景混用: +- `shape="circle"` 图标按钮 +- 旧的 `btn-icon-soft-*` +- 默认 `danger` 样式按钮 +- 新的 `ActionButton` +- 普通 `Button` 和 `ActionButton` 的不同外框语言 + +3. 不要为了“删除更危险”单独给删除按钮换不同形状、尺寸或阴影。 + +4. 不要在业务操作中继续手写 `Tooltip + Button + btn-text-* + btn-action-*`。 +优先使用 `ActionButton`。 + +## 推荐写法 + +```jsx +import ActionButton from '../components/ActionButton'; + +} + onClick={handleEdit} +/> +``` + +```jsx +} + onClick={handleDelete} +> + 删除 + +``` + +```jsx +} + onClick={handleCopy} +/> +``` + +```jsx + + + + + +``` + +## 落地约束 + +1. 新增按钮时,先判断是不是业务动作还是表单提交动作。 +- 业务动作优先使用 `ActionButton` +- 表单操作条优先使用标准 `Button` + +2. 新页面开发时,先确定场景,再选尺寸档。 +不要先凭感觉挑样式。 + +3. 旧页面调整时,如果涉及编辑、删除、查看、更多、复制、下载等业务动作,顺手迁移到 `ActionButton`。 + +4. 如果删除按钮和取消、保存并排出现,优先按表单操作条处理,不要单独给删除套胶囊业务按钮。 + +## 现状说明 + +当前平台中的主要业务操作按钮已基本迁移到 `ActionButton`,主要表单操作条也开始统一为 `删除 / 取消 / 保存` 的标准按钮组合。 + +未纳入本规范的主要是两类控件: + +- Markdown 编辑器工具栏按钮 +- 会议详情中的音频工具栏按钮 + +这两类属于功能控件,不按业务操作按钮规范处理。 diff --git a/frontend/src/App.css b/frontend/src/App.css index 933af61..ac857e5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -28,16 +28,16 @@ body { border-radius: 12px; font-weight: 600; letter-spacing: 0.01em; - transition: transform 0.18s ease, box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease; + transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease; box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06); } .ant-btn:hover { - transform: translateY(-1px); + transform: none; } .ant-btn:active { - transform: translateY(0); + transform: none; } .ant-btn .anticon { @@ -56,7 +56,7 @@ body { background: #ffffff; border-color: rgba(59, 130, 246, 0.28); color: #1d4ed8; - box-shadow: 0 10px 24px rgba(59, 130, 246, 0.12); + box-shadow: 0 8px 18px rgba(59, 130, 246, 0.1); } .ant-btn.ant-btn-primary { @@ -67,7 +67,7 @@ body { .ant-btn.ant-btn-primary:hover { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 48%, #1d4ed8 100%); - box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24); + box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18); } .ant-btn.ant-btn-primary.ant-btn-dangerous, @@ -79,7 +79,7 @@ body { .ant-btn.ant-btn-primary.ant-btn-dangerous:hover, .ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text):hover { - box-shadow: 0 14px 28px rgba(220, 38, 38, 0.24); + box-shadow: 0 10px 20px rgba(220, 38, 38, 0.18); } .ant-btn.ant-btn-link, @@ -122,7 +122,7 @@ body { background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); border-color: #93c5fd; color: #1d4ed8; - box-shadow: 0 14px 26px rgba(59, 130, 246, 0.18); + box-shadow: 0 10px 20px rgba(59, 130, 246, 0.14); } .ant-btn.btn-soft-violet, @@ -138,7 +138,7 @@ body { background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%); border-color: #c084fc; color: #6d28d9; - box-shadow: 0 14px 26px rgba(124, 58, 237, 0.18); + box-shadow: 0 10px 20px rgba(124, 58, 237, 0.14); } .ant-btn.btn-soft-green, @@ -154,67 +154,135 @@ body { background: linear-gradient(180deg, #dcfce7 0%, #bbf7d0 100%); border-color: #4ade80; color: #166534; - box-shadow: 0 14px 26px rgba(34, 197, 94, 0.18); + box-shadow: 0 10px 20px rgba(34, 197, 94, 0.14); } -.ant-btn.btn-icon-soft-blue { - background: #eff6ff; - border-color: #bfdbfe; - color: #1d4ed8; - box-shadow: none; -} - -.ant-btn.btn-icon-soft-blue:hover { - background: #dbeafe; - border-color: #93c5fd; - color: #1d4ed8; -} - -.ant-btn.btn-icon-soft-red, -.ant-btn.ant-btn-dangerous.btn-icon-soft-red { - background: #fff1f2; +.ant-btn.btn-soft-red, +.ant-btn.ant-btn-primary.btn-soft-red { + background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%); border-color: #fecdd3; color: #dc2626; + box-shadow: 0 10px 22px rgba(239, 68, 68, 0.1); +} + +.ant-btn.btn-soft-red:hover, +.ant-btn.ant-btn-primary.btn-soft-red:hover { + background: linear-gradient(180deg, #fff1f2 0%, #ffe4e6 100%); + border-color: #fda4af; + color: #b91c1c; + box-shadow: 0 10px 20px rgba(239, 68, 68, 0.12); +} + +.ant-btn.btn-pill-primary { + height: 40px; + padding-inline: 16px; + border-radius: 999px; + box-shadow: 0 10px 18px rgba(37, 99, 235, 0.18); +} + +.ant-btn.ant-btn-lg.btn-pill-primary { + height: 44px; + padding-inline: 18px; +} + +.ant-btn.btn-pill-secondary { + height: 40px; + padding-inline: 16px; + border-radius: 999px; + border-color: #dbe3ef; + background: #fff; + color: #49627f; box-shadow: none; } -.ant-btn.btn-icon-soft-red:hover, -.ant-btn.ant-btn-dangerous.btn-icon-soft-red:hover { - background: #ffe4e6; - border-color: #fda4af; - color: #b91c1c; +.ant-btn.btn-pill-secondary:hover, +.ant-btn.btn-pill-secondary:focus { + border-color: #c7d4e4; + background: #f8fafc; + color: #355171; + box-shadow: none; +} + +.ant-btn.ant-btn-lg.btn-pill-secondary { + height: 44px; + padding-inline: 18px; +} + +.ant-card-hoverable { + transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, transform 0.22s ease; +} + +.ant-card-hoverable:hover { + transform: none !important; + box-shadow: 0 12px 24px rgba(31, 78, 146, 0.12); +} + +.ant-btn.ant-btn-link.btn-text-view, +.ant-btn.ant-btn-text.btn-text-view, +.ant-btn.ant-btn-link.btn-text-neutral, +.ant-btn.ant-btn-text.btn-text-neutral, +.ant-btn.ant-btn-link.btn-text-edit, +.ant-btn.ant-btn-text.btn-text-edit, +.ant-btn.ant-btn-link.btn-text-accent, +.ant-btn.ant-btn-text.btn-text-accent, +.ant-btn.ant-btn-link.btn-text-delete, +.ant-btn.ant-btn-text.btn-text-delete, +.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete, +.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete { + border: 1px solid #d7e4f6; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88), 0 2px 8px rgba(25, 63, 121, 0.06); } .ant-btn.ant-btn-link.btn-text-view, .ant-btn.ant-btn-text.btn-text-view { color: #2563eb; + border-color: #c9ddfb; } .ant-btn.ant-btn-link.btn-text-view:hover, .ant-btn.ant-btn-text.btn-text-view:hover { - background: rgba(37, 99, 235, 0.1); + background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%); + border-color: #a9c9fa; color: #1d4ed8; } +.ant-btn.ant-btn-link.btn-text-neutral, +.ant-btn.ant-btn-text.btn-text-neutral { + color: #5f7392; + border-color: #d7e4f6; +} + +.ant-btn.ant-btn-link.btn-text-neutral:hover, +.ant-btn.ant-btn-text.btn-text-neutral:hover { + background: linear-gradient(180deg, #fbfdff 0%, #f1f6fc 100%); + border-color: #bfd0e6; + color: #355171; +} + .ant-btn.ant-btn-link.btn-text-edit, .ant-btn.ant-btn-text.btn-text-edit { - color: #0f766e; + color: #1d4ed8; + border-color: #c9ddfb; } .ant-btn.ant-btn-link.btn-text-edit:hover, .ant-btn.ant-btn-text.btn-text-edit:hover { - background: rgba(13, 148, 136, 0.1); - color: #0f766e; + background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%); + border-color: #a9c9fa; + color: #1d4ed8; } .ant-btn.ant-btn-link.btn-text-accent, .ant-btn.ant-btn-text.btn-text-accent { color: #7c3aed; + border-color: #dbc8fb; } .ant-btn.ant-btn-link.btn-text-accent:hover, .ant-btn.ant-btn-text.btn-text-accent:hover { - background: rgba(124, 58, 237, 0.1); + background: linear-gradient(180deg, #fbf8ff 0%, #f3ebff 100%); + border-color: #caa6fb; color: #6d28d9; } @@ -223,13 +291,15 @@ body { .ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete, .ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete { color: #dc2626; + border-color: #fecdd3; } .ant-btn.ant-btn-link.btn-text-delete:hover, .ant-btn.ant-btn-text.btn-text-delete:hover, .ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete:hover, .ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete:hover { - background: rgba(220, 38, 38, 0.1); + background: linear-gradient(180deg, #ffffff 0%, #fff5f5 100%); + border-color: #fda4af; color: #b91c1c; } @@ -242,6 +312,55 @@ body { min-width: 40px; } +.ant-btn.btn-action-text-lg { + min-height: 32px; + padding-inline: 12px; + border-radius: 999px; + font-size: 13px; + gap: 6px; +} + +.ant-btn.btn-action-text-sm, +.ant-btn.btn-action-inline { + min-height: 26px; + padding-inline: 10px; + border-radius: 999px; + font-size: 12px; + gap: 6px; +} + +.ant-btn.btn-action-icon-lg, +.ant-btn.btn-action-icon-sm, +.ant-btn.btn-action-compact { + gap: 0; +} + +.ant-btn.btn-action-icon-lg.ant-btn-icon-only { + width: 32px; + min-width: 32px; + height: 32px; + padding-inline: 0; + border-radius: 12px; +} + +.ant-btn.btn-action-icon-sm.ant-btn-icon-only, +.ant-btn.btn-action-compact.ant-btn-icon-only { + width: 26px; + min-width: 26px; + height: 26px; + padding-inline: 0; + border-radius: 10px; +} + +.ant-btn.btn-action-icon-lg .anticon { + font-size: 15px; +} + +.ant-btn.btn-action-icon-sm .anticon, +.ant-btn.btn-action-compact .anticon { + font-size: 13px; +} + .app-loading { display: flex; flex-direction: column; @@ -303,7 +422,7 @@ body { } .btn:hover { - transform: translateY(-1px); + transform: none; } .btn-primary { diff --git a/frontend/src/components/ActionButton.jsx b/frontend/src/components/ActionButton.jsx new file mode 100644 index 0000000..764d0a4 --- /dev/null +++ b/frontend/src/components/ActionButton.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Button, Tooltip } from 'antd'; + +const TONE_CLASS_MAP = { + view: 'btn-text-view', + neutral: 'btn-text-neutral', + edit: 'btn-text-edit', + accent: 'btn-text-accent', + delete: 'btn-text-delete', +}; + +const VARIANT_CLASS_MAP = { + textLg: 'btn-action-text-lg', + textSm: 'btn-action-text-sm', + iconLg: 'btn-action-icon-lg', + iconSm: 'btn-action-icon-sm', +}; + +const ActionButton = ({ + tone = 'neutral', + variant = 'iconSm', + tooltip, + tooltipProps, + className, + size, + children, + ...buttonProps +}) => { + const toneClass = TONE_CLASS_MAP[tone] || TONE_CLASS_MAP.neutral; + const variantClass = VARIANT_CLASS_MAP[variant] || VARIANT_CLASS_MAP.iconSm; + const resolvedSize = size ?? (variant === 'textSm' || variant === 'iconSm' ? 'small' : undefined); + const mergedClassName = [toneClass, variantClass, className].filter(Boolean).join(' '); + + const buttonNode = ( + + ); + + if (!tooltip) { + return buttonNode; + } + + return ( + + {buttonNode} + + ); +}; + +export default ActionButton; diff --git a/frontend/src/components/MeetingInfoCard.css b/frontend/src/components/MeetingInfoCard.css new file mode 100644 index 0000000..9bcb0a8 --- /dev/null +++ b/frontend/src/components/MeetingInfoCard.css @@ -0,0 +1,109 @@ +.shared-meeting-card.ant-card { + height: 224px; + border-radius: 20px; + border: 1px solid #d7e4f4; + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); + box-shadow: 0 8px 22px rgba(31, 78, 146, 0.08); + overflow: hidden; +} + +.shared-meeting-card.ant-card:hover { + border-color: #bfd5f6; + background: linear-gradient(180deg, #ffffff 0%, #f2f8ff 100%); + box-shadow: 0 14px 28px rgba(31, 78, 146, 0.12); +} + +.shared-meeting-card .ant-card-body { + padding: 16px 16px 14px; +} + +.shared-meeting-card-shell { + display: flex; + flex-direction: column; + height: 100%; +} + +.shared-meeting-card-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.shared-meeting-card-title-block { + min-height: 26px; + margin-bottom: 8px; +} + +.shared-meeting-card-title.ant-typography { + margin: 0 !important; + font-size: 18px; + line-height: 1.3; + font-weight: 700; + color: #0f172a; + word-break: break-word; +} + +.shared-meeting-card-summary { + min-height: 42px; + margin-bottom: 10px; +} + +.shared-meeting-card-summary-content.ant-typography { + margin: 0 !important; + color: #7184a0; + font-size: 13px; + line-height: 1.6; +} + +.shared-meeting-card-summary.is-empty .shared-meeting-card-summary-content.ant-typography { + color: #b1bfd1; +} + +.shared-meeting-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + margin-top: auto; + color: #6b7a90; +} + +.shared-meeting-card-meta-icon { + font-size: 15px; + color: #70819b; +} + +.shared-meeting-card-meta-text { + color: #64748b; + font-size: 13px; +} + +.shared-meeting-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 10px; +} + +.shared-meeting-card-footer-user.ant-typography { + font-size: 12px; +} + +.shared-meeting-card-footer-link { + border-radius: 999px; + padding-right: 0; +} + +@media (max-width: 767px) { + .shared-meeting-card.ant-card { + height: 216px; + } + + .shared-meeting-card-title.ant-typography { + font-size: 17px; + } +} diff --git a/frontend/src/components/MeetingTimeline.jsx b/frontend/src/components/MeetingTimeline.jsx index 57eb262..7111a38 100644 --- a/frontend/src/components/MeetingTimeline.jsx +++ b/frontend/src/components/MeetingTimeline.jsx @@ -1,12 +1,10 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { - App, Avatar, Button, Card, Divider, - Dropdown, Space, Tag, Timeline, @@ -18,15 +16,48 @@ import { ClockCircleOutlined, DeleteOutlined, EditOutlined, - FileTextOutlined, - MoreOutlined, TeamOutlined, UserOutlined, } from '@ant-design/icons'; -import MarkdownRenderer from './MarkdownRenderer'; +import ActionButton from './ActionButton'; import tools from '../utils/tools'; +import './MeetingInfoCard.css'; -const { Title, Text, Paragraph } = Typography; +const { Text, Paragraph } = Typography; + +const ROLE_META = { + creator: { + label: '我发起', + tagClass: 'console-tag-soft-blue', + }, + attendee: { + label: '我参与', + tagClass: 'console-tag-soft-green', + }, +}; + +const STATUS_META = { + completed: { + label: '已完成', + tagClass: 'console-tag-soft-green', + }, + failed: { + label: '失败', + tagClass: 'console-tag-soft-red', + }, + transcribing: { + label: '转写中', + tagClass: 'console-tag-soft-blue', + }, + summarizing: { + label: '总结中', + tagClass: 'console-tag-soft-orange', + }, + pending: { + label: '待处理', + tagClass: 'console-tag-soft-default', + }, +}; const formatDateMeta = (date) => { const parsed = new Date(date); @@ -39,6 +70,12 @@ const formatDateMeta = (date) => { }; }; +const getStatusMeta = (meeting) => STATUS_META[meeting?.overall_status || 'pending'] || STATUS_META.pending; +const getSummaryPreview = (summary) => { + if (!summary) return ''; + return tools.stripMarkdown(summary).replace(/\s+/g, ' ').trim(); +}; + const MeetingTimeline = ({ meetingsByDate, currentUser, @@ -50,7 +87,6 @@ const MeetingTimeline = ({ searchQuery = '', selectedTags = [], }) => { - const { modal } = App.useApp(); const navigate = useNavigate(); const handleEditClick = (event, meetingId) => { @@ -62,13 +98,7 @@ const MeetingTimeline = ({ const handleDeleteClick = (event, meeting) => { event.preventDefault(); event.stopPropagation(); - modal.confirm({ - title: '删除会议', - content: `确定要删除会议“${meeting.title}”吗?此操作无法撤销。`, - okText: '删除', - okType: 'danger', - onOk: () => onDeleteMeeting(meeting.meeting_id), - }); + onDeleteMeeting(meeting.meeting_id); }; const sortedDates = Object.keys(meetingsByDate).sort((a, b) => new Date(b) - new Date(a)); @@ -82,69 +112,97 @@ const MeetingTimeline = ({ {dateMeta.sub ? {dateMeta.sub} : null} ), + dot: , children: (
{meetingsByDate[date].map((meeting) => { const isCreator = String(meeting.creator_id) === String(currentUser.user_id); - const menuItems = [ - { key: 'edit', label: '编辑', icon: , onClick: ({ domEvent }) => handleEditClick(domEvent, meeting.meeting_id) }, - { key: 'delete', label: '删除', icon: , danger: true, onClick: ({ domEvent }) => handleDeleteClick(domEvent, meeting) }, - ]; + const roleMeta = isCreator ? ROLE_META.creator : ROLE_META.attendee; + const statusMeta = getStatusMeta(meeting); + const summaryPreview = getSummaryPreview(meeting.summary); + const hasSummary = Boolean(summaryPreview); return ( navigate(`/meetings/${meeting.meeting_id}`, { state: { filterContext: { filterType, searchQuery, selectedTags } }, })} > -
- - {meeting.title} - } wrap> - {tools.formatTime(meeting.meeting_time)} - {meeting.attendees?.length || 0} 人 - - {meeting.tags?.slice(0, 4).map((tag) => ( - - {tag.name} - - ))} +
+
+ + + {roleMeta.label} + + + {statusMeta.label} + + + {isCreator ? ( + + } + onClick={(event) => handleEditClick(event, meeting.meeting_id)} + /> + } + onClick={(event) => handleDeleteClick(event, meeting)} + /> - - - {isCreator ? ( - -
- - {meeting.summary ? ( -
- - - 会议摘要 - -
- - - -
+ ) : null}
- ) : null} -
- - } /> - {meeting.creator_username} - - +
+ + {meeting.title} + +
+ +
+ + {hasSummary ? summaryPreview : '暂无摘要'} + +
+ +
+ + + + {tools.formatTime(meeting.meeting_time || meeting.created_at)} + + + + + + {meeting.attendees?.length || 0} 人 + +
+ +
+ + } /> + {meeting.creator_username} + + } className="shared-meeting-card-footer-link"> + 查看详情 + +
); @@ -157,15 +215,15 @@ const MeetingTimeline = ({ return (
-
- {hasMore ? ( - - ) : ( - 已加载全部会议 - )} -
+
+ {hasMore ? ( + + ) : ( + 已加载全部会议 + )} +
); }; diff --git a/frontend/src/components/QRCodeModal.jsx b/frontend/src/components/QRCodeModal.jsx index aa9cb55..5d2a64d 100644 --- a/frontend/src/components/QRCodeModal.jsx +++ b/frontend/src/components/QRCodeModal.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { Modal, Typography, Space, Button, App } from 'antd'; import { QrcodeOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons'; import { QRCodeSVG } from 'qrcode.react'; +import ActionButton from './ActionButton'; const { Text, Paragraph } = Typography; @@ -19,7 +20,7 @@ const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享", chil open={open} onCancel={onClose} footer={[ - , + } onClick={handleCopy}>复制链接, ]} width={400} diff --git a/frontend/src/components/TagCloud.jsx b/frontend/src/components/TagCloud.jsx index 0b644a5..7e41b9e 100644 --- a/frontend/src/components/TagCloud.jsx +++ b/frontend/src/components/TagCloud.jsx @@ -33,7 +33,7 @@ const TagCloud = ({ } }; - const handleTagClick = (tag, checked) => { + const handleTagClick = (tag) => { if (onTagClick) { onTagClick(tag.name); } @@ -66,13 +66,8 @@ const TagCloud = ({ handleTagClick(tag, checked)} - style={{ - border: '1px solid #d9d9d9', - borderRadius: '4px', - padding: '2px 10px', - fontSize: '13px' - }} + onChange={() => handleTagClick(tag)} + className="console-tag-large" > {tag.name} @@ -89,7 +84,6 @@ const TagCloud = ({ closable color="blue" onClose={() => onTagClick(tag)} - style={{ borderRadius: 4 }} > {tag} diff --git a/frontend/src/components/TagDisplay.jsx b/frontend/src/components/TagDisplay.jsx index fd6a182..cbbca6e 100644 --- a/frontend/src/components/TagDisplay.jsx +++ b/frontend/src/components/TagDisplay.jsx @@ -18,13 +18,18 @@ const TagDisplay = ({ {showIcon && } {displayTags.map((tag, index) => ( - + {tag} ))} {remainingCount > 0 && ( - + +{remainingCount} diff --git a/frontend/src/components/TagEditor.jsx b/frontend/src/components/TagEditor.jsx index 88a0a6e..8d84338 100644 --- a/frontend/src/components/TagEditor.jsx +++ b/frontend/src/components/TagEditor.jsx @@ -39,7 +39,6 @@ const TagEditor = ({ tags = [], onTagsChange }) => { closable onClose={() => handleClose(tag)} color="blue" - style={{ borderRadius: 4 }} > {tag} diff --git a/frontend/src/pages/AccountSettings.jsx b/frontend/src/pages/AccountSettings.jsx index 9ef6de7..3349ff2 100644 --- a/frontend/src/pages/AccountSettings.jsx +++ b/frontend/src/pages/AccountSettings.jsx @@ -28,6 +28,7 @@ import { import apiClient from '../utils/apiClient'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; import StatusTag from '../components/StatusTag'; +import ActionButton from '../components/ActionButton'; import './AccountSettings.css'; const { Title, Paragraph, Text } = Typography; @@ -391,9 +392,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { X-BOT-ID
{mcpConfig?.bot_id || '-'}
- + } onClick={() => copyToClipboard(mcpConfig?.bot_id, 'Bot ID')}>复制 Bot ID
@@ -402,9 +401,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { X-BOT-SECRET
{mcpConfig?.bot_secret || '-'}
- + } onClick={() => copyToClipboard(mcpConfig?.bot_secret, 'Secret')}>复制 Secret 变更后旧 Secret 立即失效 diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index ec7a5d6..fb61cf9 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -37,6 +37,7 @@ import { } from '@ant-design/icons'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +import ActionButton from '../components/ActionButton'; import './AdminDashboard.css'; const { Text } = Typography; @@ -262,7 +263,7 @@ const AdminDashboard = () => { key: 'action', align: 'center', render: (_, record) => ( - + ) : ( diff --git a/frontend/src/pages/ClientManagement.jsx b/frontend/src/pages/ClientManagement.jsx index 0b19faf..0216f24 100644 --- a/frontend/src/pages/ClientManagement.jsx +++ b/frontend/src/pages/ClientManagement.jsx @@ -39,6 +39,7 @@ import { import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import AdminModuleShell from '../components/AdminModuleShell'; +import ActionButton from '../components/ActionButton'; const { Text } = Typography; const { TextArea } = Input; @@ -362,16 +363,10 @@ const ClientManagement = () => { fixed: 'right', width: 140, render: (_, record) => ( - - - + } onClick={() => setSelectedTags([])}>清空已选 ) : null}
{ 未找到符合条件的会议记录 {(searchQuery || selectedTags.length > 0 || filterType !== 'all') && ( - + )} )} diff --git a/frontend/src/pages/EditKnowledgeBase.css b/frontend/src/pages/EditKnowledgeBase.css index bc56191..fe48f43 100644 --- a/frontend/src/pages/EditKnowledgeBase.css +++ b/frontend/src/pages/EditKnowledgeBase.css @@ -180,8 +180,8 @@ } .btn-submit:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4); + transform: none; + box-shadow: 0 3px 12px rgba(16, 185, 129, 0.32); } .btn-submit:disabled { diff --git a/frontend/src/pages/EditMeeting.css b/frontend/src/pages/EditMeeting.css index d0363cf..1e2b088 100644 --- a/frontend/src/pages/EditMeeting.css +++ b/frontend/src/pages/EditMeeting.css @@ -116,8 +116,8 @@ } .regenerate-btn:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(245, 158, 11, 0.4); + transform: none; + box-shadow: 0 3px 8px rgba(245, 158, 11, 0.3); } .regenerate-btn:disabled { @@ -336,8 +336,8 @@ } .btn-submit:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4); + transform: none; + box-shadow: 0 3px 12px rgba(16, 185, 129, 0.32); } .btn-submit:disabled { @@ -379,8 +379,8 @@ .show-upload-btn:hover { background: linear-gradient(135deg, #d97706, #b45309); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(245, 158, 11, 0.4); + transform: none; + box-shadow: 0 3px 8px rgba(245, 158, 11, 0.3); } .upload-header { @@ -489,7 +489,7 @@ .upload-btn:hover:not(:disabled) { background: #5a67d8; - transform: translateY(-1px); + transform: none; } .upload-btn:disabled { @@ -637,4 +637,4 @@ .modal-actions .btn-submit:disabled { opacity: 0.7; cursor: not-allowed; -} \ No newline at end of file +} diff --git a/frontend/src/pages/HomePage.css b/frontend/src/pages/HomePage.css index 0b9ee87..9700b1e 100644 --- a/frontend/src/pages/HomePage.css +++ b/frontend/src/pages/HomePage.css @@ -107,8 +107,8 @@ .download-link:hover { background-color: var(--accent-color); color: white; - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(111, 66, 193, 0.2); + transform: none; + box-shadow: 0 3px 12px rgba(111, 66, 193, 0.16); } .login-btn { @@ -119,8 +119,8 @@ .login-btn:hover { background-color: var(--accent-color-dark); - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(111, 66, 193, 0.2); + transform: none; + box-shadow: 0 3px 12px rgba(111, 66, 193, 0.16); } /* Hero Section */ @@ -194,8 +194,8 @@ } .cta-button:hover { - transform: translateY(-3px) scale(1.05); - box-shadow: 0 7px 25px rgba(0, 0, 0, 0.15); + transform: none; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.12); } /* Features Section */ @@ -234,8 +234,8 @@ } .feature-card:hover { - transform: translateY(-8px); - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); + transform: none; + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.08); } .feature-icon { @@ -314,7 +314,7 @@ .error-message-container { min-height: 52px; display: flex; align-items: center; } .error-message { background: #fff5f5; color: #c53030; padding: 0.85rem; border-radius: 8px; border: 1px solid #fed7d7; font-size: 0.9rem; width: 100%; } .submit-btn { background: var(--accent-color); color: white; border: none; padding: 0.85rem; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; } -.submit-btn:hover:not(:disabled) { background-color: var(--accent-color-dark); transform: translateY(-2px); box-shadow: 0 4px 15px rgba(111, 66, 193, 0.2); } +.submit-btn:hover:not(:disabled) { background-color: var(--accent-color-dark); transform: none; box-shadow: 0 3px 12px rgba(111, 66, 193, 0.16); } .submit-btn:disabled { opacity: 0.6; cursor: not-allowed; } .demo-info { margin-top: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; font-size: 0.85rem; color: var(--text-light); text-align: center; } .demo-info p { margin: 0.25rem 0; } @@ -325,4 +325,4 @@ .hero-subtitle { font-size: 1.1rem; } .features-grid { grid-template-columns: 1fr; } .login-modal { margin: 1rem; padding: 2rem; } -} \ No newline at end of file +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 6cf9e24..1fd1ff8 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -16,6 +16,12 @@ import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService'; const { Title, Paragraph, Text } = Typography; +const BRAND_HIGHLIGHTS = [ + '智能转录与结构化总结', + '时间轴回看与全文检索', + '沉淀可追踪的会议资产', +]; + const HomePage = ({ onLogin }) => { const [loading, setLoading] = useState(false); const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG); @@ -68,17 +74,112 @@ const HomePage = ({ onLogin }) => { flexDirection: 'column', justifyContent: 'center' }}> -
- -
+
+
+ +
-
- - <span style={{ color: '#1677ff' }}>{branding.home_headline}</span> - - - {branding.home_tagline} - +
+ + INTELLIGENT MEETING WORKSPACE + +
+ +
+
+ + iMeeting + + + + {branding.home_headline} + +
+ + + {branding.home_tagline} + + + + + + + {BRAND_HIGHLIGHTS.map((item) => ( + + {item} + + ))} + +
diff --git a/frontend/src/pages/KnowledgeBasePage.jsx b/frontend/src/pages/KnowledgeBasePage.jsx index 35fc541..e4e04f3 100644 --- a/frontend/src/pages/KnowledgeBasePage.jsx +++ b/frontend/src/pages/KnowledgeBasePage.jsx @@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import ContentViewer from '../components/ContentViewer'; +import ActionButton from '../components/ActionButton'; import exportService from '../services/exportService'; import tools from '../utils/tools'; import meetingCacheService from '../services/meetingCacheService'; @@ -201,9 +202,9 @@ const KnowledgeBasePage = ({ user }) => {
{item.title} {selectedKb?.kb_id === item.kb_id && ( - - { e.stopPropagation(); navigate(`/knowledge-base/edit/${item.kb_id}`); }} /> - { e.stopPropagation(); handleDelete(item); }} style={{ color: '#ff4d4f' }} /> + + } onClick={(e) => { e.stopPropagation(); navigate(`/knowledge-base/edit/${item.kb_id}`); }} /> + } onClick={(e) => { e.stopPropagation(); handleDelete(item); }} /> )}
diff --git a/frontend/src/pages/MeetingCenterPage.css b/frontend/src/pages/MeetingCenterPage.css new file mode 100644 index 0000000..3617a9a --- /dev/null +++ b/frontend/src/pages/MeetingCenterPage.css @@ -0,0 +1,63 @@ +.meeting-center-page { + min-height: calc(100vh - 128px); + padding: 10px 0 28px; +} + +.meeting-center-header.ant-space { + width: 100%; + justify-content: space-between; + gap: 20px; +} + +.meeting-center-header .ant-space-item:last-child { + margin-inline-start: auto; +} + +.meeting-center-header-actions.ant-space { + justify-content: flex-end; + gap: 12px 12px; +} + +.meeting-center-search-input { + width: 240px; +} + +.meeting-center-content-panel { + min-height: 560px; +} + +.meeting-center-card.ant-card { + height: 224px; +} + +.meeting-center-empty { + min-height: 460px; + display: flex; + align-items: center; + justify-content: center; +} + +@media (max-width: 991px) { + .meeting-center-header .ant-space-item:last-child { + margin-inline-start: 0; + } + + .meeting-center-header-actions.ant-space { + width: 100%; + justify-content: flex-start; + } + + .meeting-center-search-input { + width: 100%; + } +} + +@media (max-width: 767px) { + .meeting-center-page { + padding-bottom: 20px; + } + + .meeting-center-card.ant-card { + height: 216px; + } +} diff --git a/frontend/src/pages/MeetingCenterPage.jsx b/frontend/src/pages/MeetingCenterPage.jsx index 5e06c5f..8d86865 100644 --- a/frontend/src/pages/MeetingCenterPage.jsx +++ b/frontend/src/pages/MeetingCenterPage.jsx @@ -1,30 +1,38 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { App, + Avatar, Button, Card, + Col, + Divider, Empty, Input, + Row, Segmented, Space, Tag, - Tooltip, Typography, } from 'antd'; import { - CalendarOutlined, + ArrowRightOutlined, + ClockCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, - RightOutlined, - SearchOutlined, TeamOutlined, + UserOutlined, } from '@ant-design/icons'; import { useLocation, useNavigate } from 'react-router-dom'; import apiClient from '../utils/apiClient'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; +import ActionButton from '../components/ActionButton'; import CenterPager from '../components/CenterPager'; import MeetingFormDrawer from '../components/MeetingFormDrawer'; +import meetingCacheService from '../services/meetingCacheService'; +import tools from '../utils/tools'; +import './MeetingCenterPage.css'; +import '../components/MeetingInfoCard.css'; const { Title, Text, Paragraph } = Typography; @@ -34,41 +42,42 @@ const FILTER_OPTIONS = [ { label: '我参与', value: 'attended' }, ]; +const ROLE_META = { + creator: { + label: '我发起', + tagClass: 'console-tag-soft-blue', + }, + attendee: { + label: '我参与', + tagClass: 'console-tag-soft-green', + }, +}; + const STATUS_META = { completed: { label: '已完成', - tagColor: '#52c41a', - tagBg: '#f6ffed', - tagBorder: '#b7eb8f', + tagClass: 'console-tag-soft-green', accent: '#52c41a', }, failed: { label: '失败', - tagColor: '#ff4d4f', - tagBg: '#fff1f0', - tagBorder: '#ffccc7', + tagClass: 'console-tag-soft-red', accent: '#ff4d4f', }, transcribing: { label: '转写中', - tagColor: '#1677ff', - tagBg: '#eff6ff', - tagBorder: '#bfdbfe', + tagClass: 'console-tag-soft-blue', accent: '#1677ff', }, summarizing: { label: '总结中', - tagColor: '#fa8c16', - tagBg: '#fff7e6', - tagBorder: '#ffd591', + tagClass: 'console-tag-soft-orange', accent: '#fa8c16', }, pending: { label: '待处理', - tagColor: '#8c8c8c', - tagBg: '#fafafa', - tagBorder: '#d9d9d9', - accent: '#d9d9d9', + tagClass: 'console-tag-soft-default', + accent: '#cbd5e1', }, }; @@ -77,11 +86,9 @@ const getStatusMeta = (meeting) => { return STATUS_META[status] || STATUS_META.pending; }; -const formatMeetingTime = (value) => { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '-'; - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +const getSummaryPreview = (summary) => { + if (!summary) return ''; + return tools.stripMarkdown(summary).replace(/\s+/g, ' ').trim(); }; const MeetingCenterPage = ({ user }) => { @@ -99,7 +106,20 @@ const MeetingCenterPage = ({ user }) => { const [formDrawerOpen, setFormDrawerOpen] = useState(false); const [editingMeetingId, setEditingMeetingId] = useState(null); - const loadMeetings = async (nextPage = page, nextKeyword = keyword, nextFilter = filterType) => { + const loadMeetings = useCallback(async (nextPage = page, nextKeyword = keyword, nextFilter = filterType, options = {}) => { + const { forceRefresh = false } = options; + const filterKey = meetingCacheService.generateFilterKey(user.user_id, nextFilter, nextKeyword, []); + + if (!forceRefresh) { + const cachedPage = meetingCacheService.getPage(filterKey, nextPage); + if (cachedPage) { + setMeetings(cachedPage.meetings || []); + setTotal(cachedPage.pagination?.total || 0); + setLoading(false); + return; + } + } + setLoading(true); try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { @@ -111,18 +131,26 @@ const MeetingCenterPage = ({ user }) => { search: nextKeyword || undefined, }, }); - setMeetings(res.data.meetings || []); - setTotal(res.data.total || 0); + const nextMeetings = res.data.meetings || []; + const nextPagination = { + page: res.data.page || nextPage, + total: res.data.total || 0, + has_more: Boolean(res.data.has_more), + }; + + meetingCacheService.setPage(filterKey, nextPage, nextMeetings, nextPagination); + setMeetings(nextMeetings); + setTotal(nextPagination.total); } catch (error) { message.error(error?.response?.data?.message || '加载会议失败'); } finally { setLoading(false); } - }; + }, [filterType, keyword, message, page, pageSize, user.user_id]); useEffect(() => { loadMeetings(page, keyword, filterType); - }, [page, keyword, filterType]); + }, [filterType, keyword, loadMeetings, page]); useEffect(() => { if (location.state?.openCreate) { @@ -142,9 +170,10 @@ const MeetingCenterPage = ({ user }) => { try { await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting.meeting_id))); message.success('会议已删除'); + meetingCacheService.clearAll(); const nextPage = page > 1 && meetings.length === 1 ? page - 1 : page; setPage(nextPage); - loadMeetings(nextPage, keyword, filterType); + loadMeetings(nextPage, keyword, filterType, { forceRefresh: true }); } catch (error) { message.error(error?.response?.data?.message || '删除失败'); } @@ -152,151 +181,95 @@ const MeetingCenterPage = ({ user }) => { }); }; - const footerText = useMemo(() => { - if (!total) return '当前没有会议'; - return `为您找到 ${total} 场会议`; - }, [total]); - return ( -
-
+ - -
- - - 会议中心 - - - - { - setFilterType(value); - setPage(1); - }} - options={FILTER_OPTIONS} - /> - setSearchValue(event.target.value)} - onPressEnter={() => { - setKeyword(searchValue.trim()); - setPage(1); - }} - placeholder="搜索会议标题" - prefix={} - allowClear - style={{ width: 220, borderRadius: 12 }} - /> - - + +
+ 会议中心 + 集中查看、筛选和管理全部会议记录
- - -
- {meetings.length ? ( -
+ { + setFilterType(value); + setPage(1); + }} + options={FILTER_OPTIONS} + /> + setSearchValue(event.target.value)} + onSearch={(value) => { + setSearchValue(value); + setKeyword(value.trim()); + setPage(1); + }} + placeholder="搜索会议标题" + allowClear + /> + + + + + + + {meetings.length ? ( + <> + {meetings.map((meeting) => { const statusMeta = getStatusMeta(meeting); const isCreator = String(meeting.creator_id) === String(user.user_id); + const roleMeta = isCreator ? ROLE_META.creator : ROLE_META.attendee; + const summaryPreview = getSummaryPreview(meeting.summary); + const hasSummary = Boolean(summaryPreview); + return ( - navigate(`/meetings/${meeting.meeting_id}`)} - style={{ - borderRadius: 20, - background: '#fff', - height: 240, - boxShadow: '0 18px 34px rgba(70, 92, 120, 0.08)', - position: 'relative', - overflow: 'hidden', - }} - styles={{ body: { padding: 0 } }} - > -
-
-
- - {statusMeta.label} - - {isCreator ? ( - - -
+ + ) : null} +
-
- - {meeting.title} - -
- - - - - {formatMeetingTime(meeting.meeting_time || meeting.created_at)} - - - +
item.caption).join('、') : '无参与人员' }} + className="shared-meeting-card-title" + ellipsis={{ rows: 2, tooltip: meeting.title }} > - {meeting.attendees?.length ? `${meeting.attendees.map((item) => item.caption).join('、')}` : '无参与人员'} + {meeting.title} - - +
-
-
-
-
+
+ ); })} -
- ) : ( -
- -
- )} + - -
-
+ + + ) : ( +
+ +
+ )} +
{ meetingId={editingMeetingId} user={user} onSuccess={(newMeetingId) => { + meetingCacheService.clearAll(); if (newMeetingId) { navigate(`/meetings/${newMeetingId}`); } else { - loadMeetings(page, keyword, filterType); + loadMeetings(page, keyword, filterType, { forceRefresh: true }); } }} /> diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index 37278f8..ede8d6a 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -6,7 +6,7 @@ import { Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch } from 'antd'; import { - ClockCircleOutlined, UserOutlined, + ClockCircleOutlined, UserOutlined, TeamOutlined, EditOutlined, DeleteOutlined, SettingOutlined, FireOutlined, SyncOutlined, UploadOutlined, QrcodeOutlined, @@ -14,11 +14,12 @@ import { SaveOutlined, CloseOutlined, StarFilled, RobotOutlined, DownloadOutlined, DownOutlined, CheckOutlined, - MoreOutlined, AudioOutlined + MoreOutlined, AudioOutlined, CopyOutlined } from '@ant-design/icons'; import MarkdownRenderer from '../components/MarkdownRenderer'; import MarkdownEditor from '../components/MarkdownEditor'; import MindMap from '../components/MindMap'; +import ActionButton from '../components/ActionButton'; import apiClient from '../utils/apiClient'; import tools from '../utils/tools'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; @@ -35,6 +36,39 @@ const AVATAR_COLORS = [ ]; const getSpeakerColor = (speakerId) => AVATAR_COLORS[(speakerId ?? 0) % AVATAR_COLORS.length]; +const getSummaryDisplayContent = (content) => { + if (!content) return content; + return content.replace(/^\s*#\s*(会议总结|AI总结|AI 总结)\s*\n+/i, ''); +}; + +const generateRandomPassword = (length = 4) => { + const charset = '0123456789'; + return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join(''); +}; + +const TRANSCRIPT_INITIAL_RENDER_COUNT = 80; +const TRANSCRIPT_RENDER_STEP = 120; + +const findTranscriptIndexByTime = (segments, timeMs) => { + let left = 0; + let right = segments.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const segment = segments[mid]; + + if (timeMs < segment.start_time_ms) { + right = mid - 1; + } else if (timeMs > segment.end_time_ms) { + left = mid + 1; + } else { + return mid; + } + } + + return -1; +}; + const MeetingDetails = ({ user }) => { const { meeting_id } = useParams(); const navigate = useNavigate(); @@ -43,6 +77,7 @@ const MeetingDetails = ({ user }) => { const [meeting, setMeeting] = useState(null); const [loading, setLoading] = useState(true); const [transcript, setTranscript] = useState([]); + const [transcriptLoading, setTranscriptLoading] = useState(false); const [audioUrl, setAudioUrl] = useState(null); // 发言人 @@ -52,20 +87,17 @@ const MeetingDetails = ({ user }) => { // 转录状态 const [transcriptionStatus, setTranscriptionStatus] = useState(null); const [transcriptionProgress, setTranscriptionProgress] = useState(0); - const [statusCheckInterval, setStatusCheckInterval] = useState(null); const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); // AI 总结 const [showSummaryDrawer, setShowSummaryDrawer] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryResourcesLoading, setSummaryResourcesLoading] = useState(false); const [userPrompt, setUserPrompt] = useState(''); - const [summaryHistory, setSummaryHistory] = useState([]); const [promptList, setPromptList] = useState([]); const [selectedPromptId, setSelectedPromptId] = useState(null); const [summaryTaskProgress, setSummaryTaskProgress] = useState(0); const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); - const [summaryPollInterval, setSummaryPollInterval] = useState(null); - const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null); const [llmModels, setLlmModels] = useState([]); const [selectedModelCode, setSelectedModelCode] = useState(null); @@ -82,7 +114,6 @@ const MeetingDetails = ({ user }) => { // 转录编辑 Drawer const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false); - const [editingSegmentIndex, setEditingSegmentIndex] = useState(-1); const [editingSegments, setEditingSegments] = useState({}); // 总结内容编辑(同窗口) @@ -94,10 +125,15 @@ const MeetingDetails = ({ user }) => { const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null); const [inlineSegmentValue, setInlineSegmentValue] = useState(''); const [savingInlineEdit, setSavingInlineEdit] = useState(false); + const [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT); const audioRef = useRef(null); const transcriptRefs = useRef([]); + const statusCheckIntervalRef = useRef(null); + const summaryPollIntervalRef = useRef(null); + const activeSummaryTaskIdRef = useRef(null); const isMeetingOwner = user?.user_id === meeting?.creator_id; + const creatorName = meeting?.creator_username || '未知创建人'; const hasUploadedAudio = Boolean(audioUrl); const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); const summaryDisabledReason = isUploading @@ -113,17 +149,55 @@ const MeetingDetails = ({ user }) => { useEffect(() => { fetchMeetingDetails(); - fetchPromptList(); - fetchLlmModels(); + fetchTranscript(); return () => { - if (statusCheckInterval) clearInterval(statusCheckInterval); - if (summaryPollInterval) clearInterval(summaryPollInterval); + if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); + if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); }; }, [meeting_id]); - const fetchMeetingDetails = async () => { + useEffect(() => { + if (!showSummaryDrawer) { + return; + } + + if (promptList.length > 0 && llmModels.length > 0) { + return; + } + + fetchSummaryResources(); + }, [showSummaryDrawer]); + + useEffect(() => { + transcriptRefs.current = []; + setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT)); + }, [transcript]); + + useEffect(() => { + if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) { + return; + } + + setTranscriptVisibleCount((prev) => Math.min( + transcript.length, + Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20) + )); + }, [currentHighlightIndex, transcript.length, transcriptVisibleCount]); + + useEffect(() => { + if (currentHighlightIndex < 0) { + return; + } + + transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [currentHighlightIndex, transcriptVisibleCount]); + + const fetchMeetingDetails = async (options = {}) => { + const { showPageLoading = true } = options; try { - setLoading(true); + if (showPageLoading) { + setLoading(true); + } const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); setMeeting(response.data); if (response.data.prompt_id) { @@ -149,41 +223,60 @@ const MeetingDetails = ({ user }) => { setSummaryTaskMessage(response.data.llm_status.message || ''); if (['pending', 'processing'].includes(response.data.llm_status.status)) { startSummaryPolling(response.data.llm_status.task_id); + } else { + setSummaryLoading(false); } + } else { + setSummaryLoading(false); + setSummaryTaskProgress(0); + setSummaryTaskMessage(''); } - try { - await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))); - setAudioUrl(buildApiUrl(`/api/meetings/${meeting_id}/audio/stream`)); - } catch { setAudioUrl(null); } - - fetchTranscript(); - fetchSummaryHistory(); + const hasAudio = Boolean(response.data.audio_file_path && String(response.data.audio_file_path).length > 5); + setAudioUrl(hasAudio ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null); } catch { message.error('加载会议详情失败'); } finally { - setLoading(false); + if (showPageLoading) { + setLoading(false); + } } }; const fetchTranscript = async () => { + setTranscriptLoading(true); try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))); - setTranscript(res.data); - const ids = [...new Set(res.data.map(i => i.speaker_id))].filter(id => id != null); - const list = ids.map(id => { - const item = res.data.find(s => s.speaker_id === id); - return { speaker_id: id, speaker_tag: item.speaker_tag || `发言人 ${id}` }; - }).sort((a, b) => a.speaker_id - b.speaker_id); + const segments = Array.isArray(res.data) ? res.data : []; + setTranscript(segments); + + const speakerMap = new Map(); + segments.forEach((segment) => { + if (segment?.speaker_id == null || speakerMap.has(segment.speaker_id)) { + return; + } + speakerMap.set(segment.speaker_id, { + speaker_id: segment.speaker_id, + speaker_tag: segment.speaker_tag || `发言人 ${segment.speaker_id}`, + }); + }); + + const list = Array.from(speakerMap.values()).sort((a, b) => a.speaker_id - b.speaker_id); setSpeakerList(list); const init = {}; list.forEach(s => init[s.speaker_id] = s.speaker_tag); setEditingSpeakers(init); - } catch { setTranscript([]); } + } catch { + setTranscript([]); + setSpeakerList([]); + setEditingSpeakers({}); + } finally { + setTranscriptLoading(false); + } }; const startStatusPolling = (taskId) => { - if (statusCheckInterval) clearInterval(statusCheckInterval); + if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); const interval = setInterval(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId))); @@ -192,17 +285,18 @@ const MeetingDetails = ({ user }) => { setTranscriptionProgress(status.progress || 0); if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { clearInterval(interval); + statusCheckIntervalRef.current = null; if (status.status === 'completed') { fetchTranscript(); - fetchMeetingDetails(); - setTimeout(() => { - fetchSummaryHistory(); - }, 1000); + fetchMeetingDetails({ showPageLoading: false }); } } - } catch { clearInterval(interval); } + } catch { + clearInterval(interval); + statusCheckIntervalRef.current = null; + } }, 3000); - setStatusCheckInterval(interval); + statusCheckIntervalRef.current = interval; }; const fetchPromptList = async () => { @@ -211,7 +305,9 @@ const MeetingDetails = ({ user }) => { setPromptList(res.data.prompts || []); const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; if (def) setSelectedPromptId(def.id); - } catch {} + } catch (error) { + console.debug('加载提示词列表失败:', error); + } }; const fetchLlmModels = async () => { @@ -221,16 +317,30 @@ const MeetingDetails = ({ user }) => { setLlmModels(models); const def = models.find(m => m.is_default); if (def) setSelectedModelCode(def.model_code); - } catch {} + } catch (error) { + console.debug('加载模型列表失败:', error); + } + }; + + const fetchSummaryResources = async () => { + setSummaryResourcesLoading(true); + try { + await Promise.allSettled([ + promptList.length > 0 ? Promise.resolve() : fetchPromptList(), + llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(), + ]); + } finally { + setSummaryResourcesLoading(false); + } }; const startSummaryPolling = (taskId, options = {}) => { const { closeDrawerOnComplete = false } = options; if (!taskId) return; - if (summaryPollInterval && activeSummaryTaskId === taskId) return; - if (summaryPollInterval) clearInterval(summaryPollInterval); + if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) return; + if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); - setActiveSummaryTaskId(taskId); + activeSummaryTaskIdRef.current = taskId; setSummaryLoading(true); const poll = async () => { @@ -242,63 +352,59 @@ const MeetingDetails = ({ user }) => { if (status.status === 'completed') { clearInterval(interval); - setSummaryPollInterval(null); - setActiveSummaryTaskId(null); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; setSummaryLoading(false); if (closeDrawerOnComplete) { setShowSummaryDrawer(false); } - fetchSummaryHistory(); - fetchMeetingDetails(); + fetchMeetingDetails({ showPageLoading: false }); } else if (status.status === 'failed') { clearInterval(interval); - setSummaryPollInterval(null); - setActiveSummaryTaskId(null); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; setSummaryLoading(false); message.error(status.error_message || '生成总结失败'); } } catch (error) { clearInterval(interval); - setSummaryPollInterval(null); - setActiveSummaryTaskId(null); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; setSummaryLoading(false); message.error(error?.response?.data?.message || '获取总结状态失败'); } }; const interval = setInterval(poll, 3000); - setSummaryPollInterval(interval); + summaryPollIntervalRef.current = interval; poll(); }; - const fetchSummaryHistory = async () => { - try { - const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`)); - 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 {} - }; - /* ══════════════════ 操作 ══════════════════ */ const handleTimeUpdate = () => { if (!audioRef.current) return; const timeMs = audioRef.current.currentTime * 1000; - const idx = transcript.findIndex(i => timeMs >= i.start_time_ms && timeMs <= i.end_time_ms); + const idx = findTranscriptIndexByTime(transcript, timeMs); if (idx !== -1 && idx !== currentHighlightIndex) { setCurrentHighlightIndex(idx); transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }; + const handleTranscriptScroll = (event) => { + if (transcriptVisibleCount >= transcript.length) { + return; + } + + const { scrollTop, clientHeight, scrollHeight } = event.currentTarget; + if (scrollHeight - scrollTop - clientHeight > 240) { + return; + } + + setTranscriptVisibleCount((prev) => Math.min(transcript.length, prev + TRANSCRIPT_RENDER_STEP)); + }; + const jumpToTime = (ms) => { if (audioRef.current) { audioRef.current.currentTime = ms / 1000; @@ -321,7 +427,11 @@ const MeetingDetails = ({ user }) => { try { await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData); message.success('音频上传成功'); - fetchMeetingDetails(); + setTranscript([]); + setSpeakerList([]); + setEditingSpeakers({}); + fetchMeetingDetails({ showPageLoading: false }); + fetchTranscript(); } catch { message.error('上传失败'); } finally { setIsUploading(false); } }; @@ -351,6 +461,34 @@ const MeetingDetails = ({ user }) => { } }; + const handleAccessPasswordSwitchChange = async (checked) => { + setAccessPasswordEnabled(checked); + if (checked) { + const existingPassword = (meeting?.access_password || accessPasswordDraft || '').trim(); + setAccessPasswordDraft(existingPassword || generateRandomPassword()); + return; + } + + if (!checked) { + setAccessPasswordDraft(''); + setSavingAccessPassword(true); + try { + const res = await apiClient.put( + buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)), + { password: null } + ); + setMeeting((prev) => (prev ? { ...prev, access_password: null } : prev)); + message.success(res.message || '访问密码已关闭'); + } catch (error) { + setAccessPasswordEnabled(true); + setAccessPasswordDraft(meeting?.access_password || ''); + message.error(error?.response?.data?.message || '访问密码更新失败'); + } finally { + setSavingAccessPassword(false); + } + } + }; + const copyAccessPassword = async () => { if (!accessPasswordDraft) { message.warning('当前没有可复制的访问密码'); @@ -460,6 +598,10 @@ const MeetingDetails = ({ user }) => { }; const handleDeleteMeeting = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可删除会议'); + return; + } modal.confirm({ title: '删除会议', content: '确定要删除此会议吗?此操作无法撤销。', @@ -473,6 +615,10 @@ const MeetingDetails = ({ user }) => { }; const generateSummary = async () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可重新总结'); + return; + } if (isUploading) { message.warning('音频上传中,暂不允许重新总结'); return; @@ -501,6 +647,10 @@ const MeetingDetails = ({ user }) => { }; const openSummaryDrawer = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可重新总结'); + return; + } if (isUploading) { message.warning('音频上传中,暂不允许重新总结'); return; @@ -514,7 +664,6 @@ const MeetingDetails = ({ user }) => { return; } setShowSummaryDrawer(true); - fetchSummaryHistory(); }; const downloadSummaryMd = () => { @@ -528,17 +677,6 @@ const MeetingDetails = ({ user }) => { URL.revokeObjectURL(url); }; - /* ── 转录编辑 Drawer ── */ - const openTranscriptEditDrawer = (index) => { - const segments = {}; - for (let i = Math.max(0, index - 1); i <= Math.min(transcript.length - 1, index + 1); i++) { - segments[transcript[i].segment_id] = { ...transcript[i] }; - } - setEditingSegments(segments); - setEditingSegmentIndex(index); - setShowTranscriptEditDrawer(true); - }; - const saveTranscriptEdits = async () => { try { const updates = Object.values(editingSegments).map(s => ({ @@ -549,16 +687,27 @@ const MeetingDetails = ({ user }) => { message.success('转录内容已更新'); setShowTranscriptEditDrawer(false); fetchTranscript(); - } catch { message.error('更新失败'); } + } catch (error) { + console.debug('批量更新转录失败:', error); + message.error('更新失败'); + } }; /* ── 总结内容编辑 ── */ const openSummaryEditDrawer = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可编辑总结'); + return; + } setEditingSummaryContent(meeting?.summary || ''); setIsEditingSummary(true); }; const saveSummaryContent = async () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可编辑总结'); + return; + } try { await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), { title: meeting.title, @@ -567,8 +716,8 @@ const MeetingDetails = ({ user }) => { tags: meeting.tags?.map(t => t.name).join(',') || '', }); message.success('总结已保存'); + setMeeting(prev => (prev ? { ...prev, summary: editingSummaryContent } : prev)); setIsEditingSummary(false); - fetchMeetingDetails(); } catch { message.error('保存失败'); } }; @@ -609,17 +758,19 @@ const MeetingDetails = ({ user }) => { {meeting.title} - - setEditDrawerOpen(true)} - /> - + {isMeetingOwner ? ( + } style={{ flexShrink: 0 }} onClick={() => setEditDrawerOpen(true)} /> + ) : null}
{tools.formatDateTime(meeting.meeting_time)} + 创建人:{creatorName} + + + + 参会人: {meeting.attendees?.length ? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、') : '未指定'} @@ -628,20 +779,24 @@ const MeetingDetails = ({ user }) => {
- - - - - - + {isMeetingOwner ? ( + + + + + + ) : null} + -
@@ -680,7 +835,9 @@ const MeetingDetails = ({ user }) => { {/* 转录标题栏 */}
<AudioOutlined style={{ marginRight: 6 }} />语音转录 - + {isMeetingOwner ? ( + } onClick={() => setShowSpeakerDrawer(true)}>标签 + ) : null}
{/* 音频播放器 */} @@ -689,6 +846,7 @@ const MeetingDetails = ({ user }) => {
{audioUrl ? (
{/* 转录时间轴 */} -
- {transcript.length > 0 ? ( - { - const isActive = currentHighlightIndex === index; - return { - label: ( - - {tools.formatDuration(item.start_time_ms / 1000)} - - ), - dot: ( -
- ), - children: ( -
transcriptRefs.current[index] = el} - style={{ - padding: '6px 10px', - borderRadius: 8, - background: isActive ? '#e6f4ff' : 'transparent', - cursor: 'pointer', - transition: 'background 0.2s', - marginLeft: -4, - }} - onClick={() => jumpToTime(item.start_time_ms)} - > -
- } - style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }} - /> - {inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? ( - e.stopPropagation()}> - + {transcriptLoading ? ( +
+ +
+ ) : transcript.length > 0 ? ( + <> + { + const isActive = currentHighlightIndex === index; + return { + label: ( + + {tools.formatDuration(item.start_time_ms / 1000)} + + ), + dot: ( +
+ ), + children: ( +
transcriptRefs.current[index] = el} + style={{ + padding: '6px 10px', + borderRadius: 8, + background: isActive ? '#e6f4ff' : 'transparent', + cursor: 'pointer', + transition: 'background 0.2s', + marginLeft: -4, + }} + onClick={() => jumpToTime(item.start_time_ms)} + > +
+ } + style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }} + /> + {inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? ( + e.stopPropagation()}> + setInlineSpeakerValue(e.target.value)} + onPressEnter={saveInlineSpeakerEdit} + style={{ width: 180 }} + /> +
+ {inlineSegmentEditId === item.segment_id ? ( +
e.stopPropagation()}> + setInlineSpeakerValue(e.target.value)} - onPressEnter={saveInlineSpeakerEdit} - style={{ width: 180 }} + autoSize={{ minRows: 2, maxRows: 6 }} + value={inlineSegmentValue} + onChange={(e) => setInlineSegmentValue(e.target.value)} + onPressEnter={(e) => { + if (e.ctrlKey || e.metaKey) { + saveInlineSegmentEdit(); + } + }} /> - + + +
) : ( { + style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: isMeetingOwner ? 'text' : 'default' }} + onDoubleClick={isMeetingOwner ? (e) => { e.stopPropagation(); - startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id); - }} + startInlineSegmentEdit(item); + } : undefined} > - {item.speaker_tag || `发言人 ${item.speaker_id}`} - + {item.text_content} )}
- {inlineSegmentEditId === item.segment_id ? ( -
e.stopPropagation()}> - setInlineSegmentValue(e.target.value)} - onPressEnter={(e) => { - if (e.ctrlKey || e.metaKey) { - saveInlineSegmentEdit(); - } - }} - /> - - - - -
- ) : ( - { - e.stopPropagation(); - startInlineSegmentEdit(item); - }} - > - {item.text_content} - - )} -
- ), - }; - })} - /> + ), + }; + })} + /> + {transcriptVisibleCount < transcript.length ? ( +
+ + 已渲染 {transcriptVisibleCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多 + +
+ ) : null} + ) : ( )} @@ -868,14 +1048,16 @@ const MeetingDetails = ({ user }) => {
{/* 操作栏 */}
- {isEditingSummary ? ( - - - - - ) : ( - - )} + {isMeetingOwner ? ( + isEditingSummary ? ( + + + + + ) : ( + } onClick={openSummaryEditDrawer}>编辑 + ) + ) : null}
{/* 内容区 */}
@@ -890,7 +1072,7 @@ const MeetingDetails = ({ user }) => { />
) : meeting.summary ? ( - + ) : ( { onClose={() => setShowSummaryDrawer(false)} destroyOnClose extra={ - - - + isMeetingOwner ? ( + + + + ) : null } > {/* 模板选择 */}
选择总结模板 + {summaryResourcesLoading ? : null} {promptList.length ? promptList.map(p => { const isSelected = selectedPromptId === p.id; const isSystem = Number(p.is_system) === 1; @@ -979,7 +1164,7 @@ const MeetingDetails = ({ user }) => { {isSystem ? 系统 : 个人} {p.is_default ? }>默认 : null} - - + isMeetingOwner ? ( + + + + ) : null } > { destroyOnClose extra={ - + } > {Object.values(editingSegments) .sort((a, b) => a.start_time_ms - b.start_time_ms) .map((seg) => { - const isCurrent = transcript[editingSegmentIndex]?.segment_id === seg.segment_id; return ( { style={{ marginBottom: 12, borderRadius: 10, - borderLeft: isCurrent ? '4px solid #1677ff' : '4px solid transparent', - background: isCurrent ? '#f0f7ff' : '#fff', + borderLeft: '4px solid transparent', + background: '#fff', }} > @@ -1166,29 +1353,27 @@ const MeetingDetails = ({ user }) => { checked={accessPasswordEnabled} checkedChildren="已开启" unCheckedChildren="已关闭" - onChange={setAccessPasswordEnabled} + loading={savingAccessPassword} + onChange={handleAccessPasswordSwitchChange} />
{accessPasswordEnabled ? ( - - setAccessPasswordDraft(e.target.value)} - placeholder="请输入访问密码" - /> - - 开启后,访客打开分享链接时需要输入这个密码 - - - - - - + <> + + setAccessPasswordDraft(e.target.value)} + placeholder="请输入访问密码" + /> + } disabled={!accessPasswordDraft} onClick={copyAccessPassword} /> + } loading={savingAccessPassword} onClick={saveAccessPassword} /> + + + 开启后,访客打开分享链接时需要输入这个密码 + + ) : ( - - 关闭后,任何拿到链接的人都可以直接查看预览页 - - + 关闭后,任何拿到链接的人都可以直接查看预览页 )} ) : meeting?.access_password ? ( @@ -1199,11 +1384,11 @@ const MeetingDetails = ({ user }) => { ) : null} setEditDrawerOpen(false)} meetingId={meeting_id} user={user} - onSuccess={() => fetchMeetingDetails()} + onSuccess={() => fetchMeetingDetails({ showPageLoading: false })} />
); diff --git a/frontend/src/pages/MeetingPreview.css b/frontend/src/pages/MeetingPreview.css index ce025e7..67a6ebe 100644 --- a/frontend/src/pages/MeetingPreview.css +++ b/frontend/src/pages/MeetingPreview.css @@ -87,8 +87,8 @@ .error-retry-btn:hover { background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%); - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); - transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(37, 99, 235, 0.32); + transform: none; } .error-retry-btn:active { @@ -100,7 +100,7 @@ max-width: 800px; width: 100%; background: white; - padding: 40px; + padding: 32px 32px 36px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); margin: 20px auto; @@ -113,26 +113,33 @@ .preview-title { color: #2563eb; - font-size: 28px; - margin-bottom: 30px; + font-size: 24px; + margin-bottom: 22px; border-bottom: 2px solid #e5e7eb; - padding-bottom: 15px; + padding-bottom: 12px; text-align: center; } .meeting-info-section { background: #f9fafb; - padding: 25px; - margin-bottom: 35px; - border-radius: 8px; + padding: 18px 20px; + margin-bottom: 24px; + border-radius: 12px; border: 1px solid #e5e7eb; } .section-title { color: #374151; - font-size: 20px; - margin: 0 0 16px; - padding-bottom: 10px; + font-size: 16px; + line-height: 1.2; + margin: 0 0 10px; + padding-bottom: 0; +} + +.info-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; } /* 操作按钮行 */ @@ -169,8 +176,8 @@ .copy-btn:hover { background: linear-gradient(135deg, #5a67d8, #6b46c1); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35); + transform: none; + box-shadow: 0 3px 10px rgba(102, 126, 234, 0.28); } .copy-btn:active { @@ -187,8 +194,8 @@ .share-btn:hover { background: linear-gradient(135deg, #059669, #047857); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35); + transform: none; + box-shadow: 0 3px 10px rgba(16, 185, 129, 0.28); } .share-btn:active { @@ -197,8 +204,9 @@ } .info-item { - margin: 12px 0; - font-size: 16px; + margin: 0; + font-size: 15px; + line-height: 1.6; color: #475569; } @@ -297,21 +305,27 @@ } .preview-content { - padding: 20px 15px; + padding: 18px 14px 24px; margin: 10px auto; } .preview-title { - font-size: 20px; - margin-bottom: 20px; + font-size: 16px; + margin-bottom: 14px; + padding-bottom: 10px; } .section-title { - font-size: 18px; + font-size: 14px; } .info-item { - font-size: 14px; + font-size: 13px; + } + + .meeting-info-section { + padding: 14px 14px 12px; + margin-bottom: 16px; } /* Tab 移动端优化 */ @@ -359,7 +373,7 @@ } .preview-title { - font-size: 24px; + font-size: 22px; } /* 思维导图平板优化 */ @@ -557,8 +571,8 @@ .password-verify-btn:hover:not(:disabled) { background: linear-gradient(135deg, #d97706 0%, #b45309 100%); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4); + transform: none; + box-shadow: 0 3px 10px rgba(245, 158, 11, 0.32); } .password-verify-btn:active:not(:disabled) { diff --git a/frontend/src/pages/MeetingPreview.jsx b/frontend/src/pages/MeetingPreview.jsx index 9cc7a3b..f262b57 100644 --- a/frontend/src/pages/MeetingPreview.jsx +++ b/frontend/src/pages/MeetingPreview.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { Layout, Space, Button, App, Tag, Empty, Input, Tabs } from 'antd'; +import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd'; import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, UserOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; @@ -106,7 +106,9 @@ const MeetingPreview = () => { try { await navigator.share({ title: meeting?.title || branding.preview_title, url: window.location.href }); return; - } catch {} + } catch { + // Fallback to copying the link when native share is cancelled or unavailable. + } } await handleCopyLink(); }; @@ -210,19 +212,12 @@ const MeetingPreview = () => {

会议信息

-
创建人:{meeting.creator_username || '未知'}
-
会议时间:{tools.formatDateTime(meeting.meeting_time)}
-
参会人员:{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}
-
计算人数:{meeting.attendees_count || meeting.attendees?.length || 0}人
-
总结模板:{meeting.prompt_name || '默认模板'}
- {meeting.tags?.length ? ( -
- 标签: - - {meeting.tags.map((tag) => {tag.name})} - -
- ) : null} +
+
创建人:{meeting.creator_username || '未知'}
+
会议时间:{tools.formatDateTime(meeting.meeting_time)}
+
参会人员:{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}
+
计算人数:{meeting.attendees_count || meeting.attendees?.length || 0}人
+
@@ -245,12 +240,26 @@ const MeetingPreview = () => { label: ( - 会议总结 + 总结 ), children: (
- {meeting.summary ? : } + {meeting.summary ? : } +
+ ), + }, + { + key: 'mindmap', + label: ( + + + 思维导图 + + ), + children: ( +
+
), }, @@ -300,21 +309,7 @@ const MeetingPreview = () => { )}
), - }, - { - key: 'mindmap', - label: ( - - - 思维导图 - - ), - children: ( -
- -
- ), - }, + } ]} />
diff --git a/frontend/src/pages/PromptConfigPage.jsx b/frontend/src/pages/PromptConfigPage.jsx index 68fe726..baf2302 100644 --- a/frontend/src/pages/PromptConfigPage.jsx +++ b/frontend/src/pages/PromptConfigPage.jsx @@ -26,12 +26,13 @@ import apiClient from '../utils/apiClient'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; import PromptManagementPage from './PromptManagementPage'; import MarkdownRenderer from '../components/MarkdownRenderer'; +import ActionButton from '../components/ActionButton'; const { Title, Text, Paragraph } = Typography; const TASK_TYPES = [ - { label: '会议总结', value: 'MEETING_TASK' }, - { label: '知识库整合', value: 'KNOWLEDGE_TASK' }, + { label: '总结模版', value: 'MEETING_TASK' }, + { label: '知识库模版', value: 'KNOWLEDGE_TASK' }, ]; const TASK_TYPE_OPTIONS = TASK_TYPES.map((item) => ({ label: item.label, value: item.value })); @@ -167,12 +168,7 @@ const PromptConfigPage = ({ user }) => { {isSystem ? 系统 : 个人} {isSystem && item.is_default ? }>默认 : null} -
- -
diff --git a/frontend/src/pages/admin/DictManagement.jsx b/frontend/src/pages/admin/DictManagement.jsx index 9ee4c12..8efb3ca 100644 --- a/frontend/src/pages/admin/DictManagement.jsx +++ b/frontend/src/pages/admin/DictManagement.jsx @@ -11,7 +11,6 @@ import { } from '@ant-design/icons'; import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; - const { Option } = Select; const { TextArea } = Input; const { Title, Text } = Typography; @@ -285,7 +284,7 @@ const DictManagement = () => { okText="确定" cancelText="取消" > - + )} diff --git a/frontend/src/pages/admin/ExternalAppManagement.jsx b/frontend/src/pages/admin/ExternalAppManagement.jsx index b0e1523..d00bce7 100644 --- a/frontend/src/pages/admin/ExternalAppManagement.jsx +++ b/frontend/src/pages/admin/ExternalAppManagement.jsx @@ -38,6 +38,7 @@ import { import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import AdminModuleShell from '../../components/AdminModuleShell'; +import ActionButton from '../../components/ActionButton'; const { Text } = Typography; const { TextArea } = Input; @@ -330,23 +331,10 @@ const ExternalAppManagement = () => { fixed: 'right', width: 140, render: (_, record) => ( - - -
@@ -379,7 +360,7 @@ const HotWordManagement = () => {
{/* ── Left: Group list ── */}
- @@ -398,7 +379,13 @@ const HotWordManagement = () => { {selectedGroup ? ( <> {/* 选中组的阿里云同步详情 */} - + {selectedGroup.name} @@ -407,7 +394,7 @@ const HotWordManagement = () => { {selectedGroup.vocabulary_id} ) : ( - 未同步 + )} @@ -417,26 +404,33 @@ const HotWordManagement = () => { {/* Toolbar */} -
- - } - placeholder="搜索热词" onChange={(e) => { setKeyword(e.target.value); setPage(1); }} - style={{ width: 220 }} - /> - } + placeholder="搜索热词" onChange={(e) => { setKeyword(e.target.value); setPage(1); }} + style={{ width: 220 }} + /> + - 会议总结 - 知识库整合 + 总结模版 + 知识库模版 diff --git a/frontend/src/pages/admin/SystemConfiguration.css b/frontend/src/pages/admin/SystemConfiguration.css index 432f0b4..e792404 100644 --- a/frontend/src/pages/admin/SystemConfiguration.css +++ b/frontend/src/pages/admin/SystemConfiguration.css @@ -162,8 +162,8 @@ } .config-btn-primary:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3); + transform: none; + box-shadow: 0 3px 8px rgba(59, 130, 246, 0.24); } .config-btn-primary:disabled { @@ -247,4 +247,4 @@ .config-btn { justify-content: center; } -} \ No newline at end of file +} diff --git a/frontend/src/pages/admin/TerminalManagement.jsx b/frontend/src/pages/admin/TerminalManagement.jsx index 206ef3b..390a66e 100644 --- a/frontend/src/pages/admin/TerminalManagement.jsx +++ b/frontend/src/pages/admin/TerminalManagement.jsx @@ -33,6 +33,7 @@ import { import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import AdminModuleShell from '../../components/AdminModuleShell'; +import ActionButton from '../../components/ActionButton'; const { Text } = Typography; const { TextArea } = Input; @@ -299,13 +300,9 @@ const TerminalManagement = () => { fixed: 'right', width: 100, render: (_, record) => ( - - -