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
+
+ } onClick={handleDelete}>
+ 删除
+
+ } onClick={handleCancel}>
+ 取消
+
+ } onClick={handleSave}>
+ 保存
+
+
+```
+
+## 落地约束
+
+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 ? (
-
- } className="timeline-action-trigger" onClick={(event) => event.stopPropagation()} />
-
- ) : null}
-
-
- {meeting.summary ? (
-
-
-
- 会议摘要
-
-
+ ) : null}
- ) : null}
-
-
- } />
- {meeting.creator_username}
-
-
} className="timeline-footer-link">
- 查看详情
-
+
+
+
+
+ {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}>复制链接,
+
} onClick={handleCopy}>复制链接,
} className="btn-soft-green" onClick={onClose}>完成
]}
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
-
+
} 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
-
+ } 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) => (
- } onClick={() => handleKickUser(record)} />
+ } onClick={() => handleKickUser(record)} />
),
},
];
@@ -281,11 +282,9 @@ const AdminDashboard = () => {
key: 'meeting_title',
render: (text, record) => (
- {record.meeting_id && (
-
- } onClick={() => handleViewMeeting(record.meeting_id)} />
-
- )}
+ {record.meeting_id ? (
+ } onClick={() => handleViewMeeting(record.meeting_id)} />
+ ) : null}
{text || '-'}
),
@@ -517,9 +516,9 @@ const AdminDashboard = () => {
: '无时长信息'}
- } onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
+ } onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
下载转录结果 (JSON)
-
+
) : (
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={() => openModal(record)} />
-
-
- } href={record.download_url} target="_blank" />
-
-
- } onClick={() => handleDelete(record)} />
-
+
+ } onClick={() => openModal(record)} />
+ } href={record.download_url} target="_blank" />
+ } onClick={() => handleDelete(record)} />
),
},
diff --git a/frontend/src/pages/CreateMeeting.css b/frontend/src/pages/CreateMeeting.css
index fbd387d..9a083e6 100644
--- a/frontend/src/pages/CreateMeeting.css
+++ b/frontend/src/pages/CreateMeeting.css
@@ -313,8 +313,8 @@
}
.btn-submit:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
+ transform: none;
+ box-shadow: 0 3px 12px rgba(59, 130, 246, 0.32);
}
.btn-submit:disabled {
@@ -348,4 +348,4 @@
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css
index dc667fa..66e62dc 100644
--- a/frontend/src/pages/Dashboard.css
+++ b/frontend/src/pages/Dashboard.css
@@ -1,13 +1,15 @@
.dashboard-v3 {
- padding-bottom: 40px;
+ min-height: calc(100vh - 128px);
+ padding: 10px 0 28px;
}
.dashboard-hero-card,
.dashboard-main-card {
- border-radius: 24px;
- border: 1px solid #dbe6f2;
- box-shadow: 0 18px 38px rgba(31, 78, 146, 0.08);
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #f8fbff 100%);
+ border-radius: 18px;
+}
+
+.dashboard-hero-card .ant-card-body {
+ padding: 20px 24px;
}
.dashboard-user-block {
@@ -49,8 +51,8 @@
}
.dashboard-stat-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 18px 34px rgba(31, 78, 146, 0.12);
+ transform: none;
+ box-shadow: 0 14px 28px rgba(31, 78, 146, 0.11);
}
.dashboard-stat-card.active {
@@ -134,20 +136,8 @@
}
.dashboard-icon-button.active {
- border-color: #93c5fd;
- background: #dbeafe;
- color: #1d4ed8;
-}
-
-.dashboard-search-row .dashboard-icon-button:nth-of-type(2) {
- border-color: #bfdbfe;
- background: #eff6ff;
- color: #2563eb;
-}
-
-.dashboard-search-row .dashboard-icon-button:nth-of-type(2):hover {
- background: #dbeafe;
- border-color: #93c5fd;
+ border-color: #c9ddfb;
+ background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
color: #1d4ed8;
}
@@ -176,6 +166,9 @@
.dashboard-main-content {
padding: 24px 22px 28px;
+ min-height: 520px;
+ background: #f7fafc;
+ border-radius: 0 0 20px 20px;
}
.dashboard-timeline-wrap {
@@ -198,96 +191,57 @@
}
.modern-timeline .ant-timeline-item {
- padding-bottom: 28px;
+ padding-bottom: 24px;
}
.modern-timeline .ant-timeline-item-label {
- width: 118px !important;
+ width: 72px !important;
+ text-align: right;
}
.modern-timeline .ant-timeline-item-tail,
.modern-timeline .ant-timeline-item-head {
- inset-inline-start: 136px !important;
+ inset-inline-start: 84px !important;
}
.modern-timeline .ant-timeline-item-content {
- inset-inline-start: 160px !important;
- width: calc(100% - 178px) !important;
+ inset-inline-start: 96px !important;
+ width: calc(100% - 112px) !important;
}
.timeline-date-label {
- padding-right: 12px;
+ padding-right: 10px;
text-align: right;
- opacity: 0.78;
+ opacity: 0.88;
}
.timeline-date-main {
display: block;
font-size: 13px;
- color: #6b84a6;
+ color: #5f7595;
font-weight: 700;
+ white-space: nowrap;
}
.timeline-date-sub {
display: block;
margin-top: 2px;
- color: #a0b2c9;
+ color: #91a5bf;
font-size: 11px;
}
+.timeline-date-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: linear-gradient(180deg, #2563eb 0%, #38bdf8 100%);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
+}
+
.timeline-date-group {
display: flex;
flex-direction: column;
- gap: 14px;
-}
-
-.timeline-meeting-card {
- margin-bottom: 12px;
- border-radius: 20px;
- border: 1px solid #dbe5f2;
- box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06);
-}
-
-.timeline-meeting-card .ant-card-body {
- padding: 16px 18px;
-}
-
-.timeline-summary-box {
- background: linear-gradient(180deg, #f8fbff 0%, #f2f7ff 100%);
- padding: 12px 13px;
- border-radius: 14px;
- border: 1px solid #e2ebf6;
- margin-bottom: 14px;
-}
-
-.timeline-summary-content {
- min-height: 44px;
- display: flex;
- align-items: flex-start;
-}
-
-.timeline-action-trigger {
- color: #8ba0bb;
- border-radius: 12px;
-}
-
-.timeline-action-trigger:hover {
- background: #f5f8fc;
- color: #375a8c;
-}
-
-.timeline-footer-link {
- color: #7b93b2;
- border-radius: 12px;
-}
-
-.timeline-footer-link:hover {
- background: #f5f8fc;
- color: #1d4ed8;
-}
-
-.timeline-footer-link {
- padding-right: 0;
+ gap: 12px;
}
@media (max-width: 992px) {
@@ -304,17 +258,17 @@
}
.modern-timeline .ant-timeline-item-label {
- width: 82px !important;
+ width: 72px !important;
}
.modern-timeline .ant-timeline-item-tail,
.modern-timeline .ant-timeline-item-head {
- inset-inline-start: 100px !important;
+ inset-inline-start: 84px !important;
}
.modern-timeline .ant-timeline-item-content {
- inset-inline-start: 118px !important;
- width: calc(100% - 132px) !important;
+ inset-inline-start: 96px !important;
+ width: calc(100% - 112px) !important;
}
}
@@ -341,6 +295,10 @@
width: calc(100% - 28px) !important;
margin-top: 18px;
}
+
+ .timeline-card-title.ant-typography {
+ font-size: 18px;
+ }
}
.prompt-config-segmented.ant-segmented {
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index dc609ef..ca11a3c 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -31,6 +31,7 @@ import {
import { useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import { API_ENDPOINTS, buildApiUrl } from '../config/api';
+import ActionButton from '../components/ActionButton';
import MeetingTimeline from '../components/MeetingTimeline';
import TagCloud from '../components/TagCloud';
import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal';
@@ -204,7 +205,7 @@ const Dashboard = ({ user }) => {
try {
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
setUserInfo(userResponse.data);
- } catch (error) {
+ } catch {
message.error('获取用户信息失败');
}
};
@@ -266,7 +267,7 @@ const Dashboard = ({ user }) => {
return (
-
+
@@ -284,7 +285,7 @@ const Dashboard = ({ user }) => {
}
- style={{ cursor: 'pointer', borderRadius: 999, paddingInline: 10 }}
+ className="console-tag-clickable"
onClick={voiceprintStatus?.has_voiceprint ? handleDeleteVoiceprint : () => setShowVoiceprintModal(true)}
>
{voiceprintStatus?.has_voiceprint ? '声纹已采' : '未采声纹'}
@@ -327,7 +328,7 @@ const Dashboard = ({ user }) => {
-
+
{
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={() => setSearchQuery(searchInput.trim())}
/>
-
- }
- size="large"
- onClick={() => setSearchQuery(searchInput.trim())}
- className="dashboard-icon-button"
- />
-
-
- }
- size="large"
- onClick={() => setShowTagFilters((prev) => !prev)}
- className={`dashboard-icon-button ${showTagFilters ? 'active' : ''}`}
- />
-
+
}
+ onClick={() => setSearchQuery(searchInput.trim())}
+ className="dashboard-icon-button"
+ />
+
}
+ onClick={() => setShowTagFilters((prev) => !prev)}
+ className={`dashboard-icon-button ${showTagFilters ? 'active' : ''}`}
+ />
@@ -363,7 +364,7 @@ const Dashboard = ({ user }) => {
type="primary"
size="large"
icon={}
- className="dashboard-add-button"
+ className="dashboard-add-button btn-pill-primary"
onClick={() => navigate('/meetings/center', { state: { openCreate: true } })}
>
新建会议
@@ -380,7 +381,7 @@ const Dashboard = ({ user }) => {
点击标签可快速筛选会议时间轴
{selectedTags.length > 0 ? (
- } onClick={() => setSelectedTags([])}>清空已选
+ } onClick={() => setSelectedTags([])}>清空已选
) : null}
{
未找到符合条件的会议记录
{(searchQuery || selectedTags.length > 0 || filterType !== 'all') && (
- }
onClick={() => {
setSearchInput('');
@@ -431,7 +433,7 @@ const Dashboard = ({ user }) => {
}}
>
清除所有过滤
-
+
)}
)}
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'
}}>
-
-
-
+
+
+
+
-
-
- {branding.home_headline}
-
-
- {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 }}
- />
- }
- onClick={() => { setEditingMeetingId(null); setFormDrawerOpen(true); }}
- style={{ borderRadius: 12, height: 40, boxShadow: '0 10px 18px rgba(22, 119, 255, 0.2)' }}
- >
- 新建会议
-
-
+
+
+
会议中心
+ 集中查看、筛选和管理全部会议记录
-
-
-
- {meetings.length ? (
-
+
{
+ setFilterType(value);
+ setPage(1);
+ }}
+ options={FILTER_OPTIONS}
+ />
+ setSearchValue(event.target.value)}
+ onSearch={(value) => {
+ setSearchValue(value);
+ setKeyword(value.trim());
+ setPage(1);
+ }}
+ placeholder="搜索会议标题"
+ allowClear
+ />
+ }
+ className="btn-pill-primary"
+ onClick={() => {
+ setEditingMeetingId(null);
+ setFormDrawerOpen(true);
}}
>
+ 新建会议
+
+
+
+
+
+
+ {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 ? (
-
-
-
-
+
+
);
})}
-
- ) : (
-
-
-
- )}
+
-
-
-
+
+ >
+ ) : (
+
+
+
+ )}
+
{
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 }) => {
-
-
- } onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
- 重新总结
-
-
-
- } onClick={downloadSummaryMd}>下载总结
+ {isMeetingOwner ? (
+
+
+ } onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
+ 重新总结
+
+
+
+ ) : null}
+ } onClick={downloadSummaryMd}>下载总结
- } onClick={() => setShowQRModal(true)} />
+ } onClick={() => setShowQRModal(true)} />
-
- } />
-
+ {isMeetingOwner ? (
+
+ } />
+
+ ) : null}
@@ -680,7 +835,9 @@ const MeetingDetails = ({ user }) => {
{/* 转录标题栏 */}
语音转录
-
} onClick={() => setShowSpeakerDrawer(true)}>标签
+ {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 }}
+ />
+ }
+ loading={savingInlineEdit}
+ onClick={saveInlineSpeakerEdit}
+ />
+ }
+ disabled={savingInlineEdit}
+ onClick={cancelInlineSpeakerEdit}
+ />
+
+ ) : (
+
{
+ e.stopPropagation();
+ startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
+ } : undefined}
+ >
+ {item.speaker_tag || `发言人 ${item.speaker_id}`}
+ {isMeetingOwner ? : null}
+
+ )}
+
+ {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();
+ }
+ }}
/>
- }
- loading={savingInlineEdit}
- onClick={saveInlineSpeakerEdit}
- />
- }
- disabled={savingInlineEdit}
- onClick={cancelInlineSpeakerEdit}
- />
-
+
+ } loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
+ 保存
+
+ } disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
+ 取消
+
+
+
) : (
{
+ 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();
- }
- }}
- />
-
- } loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
- 保存
-
- } disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
- 取消
-
-
-
- ) : (
- {
- e.stopPropagation();
- startInlineSegmentEdit(item);
- }}
- >
- {item.text_content}
-
- )}
-
- ),
- };
- })}
- />
+ ),
+ };
+ })}
+ />
+ {transcriptVisibleCount < transcript.length ? (
+
+
+ 已渲染 {transcriptVisibleCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
+
+
+ ) : null}
+ >
) : (
)}
@@ -868,14 +1048,16 @@ const MeetingDetails = ({ user }) => {
{/* 操作栏 */}
- {isEditingSummary ? (
-
- } onClick={() => setIsEditingSummary(false)}>取消
- } onClick={saveSummaryContent}>保存
-
- ) : (
-
} onClick={openSummaryEditDrawer}>编辑
- )}
+ {isMeetingOwner ? (
+ isEditingSummary ? (
+
+ } onClick={() => setIsEditingSummary(false)}>取消
+ } onClick={saveSummaryContent}>保存
+
+ ) : (
+
} onClick={openSummaryEditDrawer}>编辑
+ )
+ ) : null}
{/* 内容区 */}
@@ -890,7 +1072,7 @@ const MeetingDetails = ({ user }) => {
/>
) : meeting.summary ? (
-
+
) : (
{
onClose={() => setShowSummaryDrawer(false)}
destroyOnClose
extra={
-
- } loading={summaryLoading} onClick={generateSummary}>生成总结
-
+ isMeetingOwner ? (
+
+ } loading={summaryLoading} onClick={generateSummary}>生成总结
+
+ ) : 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}
- } onClick={e => { e.stopPropagation(); setViewingPrompt(p); }} />
+ } onClick={e => { e.stopPropagation(); setViewingPrompt(p); }} />
{p.desc &&
{p.desc}}
@@ -1001,13 +1186,14 @@ const MeetingDetails = ({ user }) => {
onChange={setSelectedModelCode}
placeholder="选择模型(默认使用系统配置)"
allowClear
+ loading={summaryResourcesLoading}
>
{llmModels.map(m => (
{m.model_name}
{m.provider}
- {m.is_default ? 默认 : null}
+ {m.is_default ? 默认 : null}
))}
@@ -1046,15 +1232,17 @@ const MeetingDetails = ({ user }) => {
onClose={() => setShowSpeakerDrawer(false)}
destroyOnClose
extra={
-
- } onClick={async () => {
- const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({ speaker_id: parseInt(id), new_tag: tag }));
- await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { updates });
- setShowSpeakerDrawer(false);
- fetchTranscript();
- message.success('更新成功');
- }}>保存
-
+ isMeetingOwner ? (
+
+ } onClick={async () => {
+ const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({ speaker_id: parseInt(id), new_tag: tag }));
+ await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { updates });
+ setShowSpeakerDrawer(false);
+ fetchTranscript();
+ message.success('更新成功');
+ }}>保存
+
+ ) : null
}
>
{
destroyOnClose
extra={
- } onClick={saveTranscriptEdits}>保存
+ } onClick={saveTranscriptEdits}>保存
}
>
{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}
- }
- onClick={() => setViewingPrompt(item)}
- />
+ } onClick={() => setViewingPrompt(item)} />
{item.desc && (
diff --git a/frontend/src/pages/PromptManagementPage.jsx b/frontend/src/pages/PromptManagementPage.jsx
index 8cbb746..f8fc79c 100644
--- a/frontend/src/pages/PromptManagementPage.jsx
+++ b/frontend/src/pages/PromptManagementPage.jsx
@@ -31,12 +31,13 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownEditor from '../components/MarkdownEditor';
import CenterPager from '../components/CenterPager';
import StatusTag from '../components/StatusTag';
+import ActionButton from '../components/ActionButton';
const { Title, Text } = 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 }));
@@ -194,22 +195,8 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
-
- }
- onClick={() => openEditDrawer(prompt)}
- />
-
-
- }
- onClick={() => removePrompt(prompt)}
- />
-
+ } onClick={() => openEditDrawer(prompt)} />
+ } onClick={() => removePrompt(prompt)} />
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="取消"
>
- }>删除
+ } className="btn-soft-red">删除
)}
} onClick={handleCancel}>取消
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) => (
-
-
- }
- href={getAppEntryUrl(record)}
- target="_blank"
- disabled={!getAppEntryUrl(record)}
- />
-
-
- } onClick={() => handleOpenModal(record)} />
-
-
- } onClick={() => handleDelete(record)} />
-
+
+ } href={getAppEntryUrl(record)} target="_blank" disabled={!getAppEntryUrl(record)} />
+ } onClick={() => handleOpenModal(record)} />
+ } onClick={() => handleDelete(record)} />
),
},
diff --git a/frontend/src/pages/admin/HotWordManagement.jsx b/frontend/src/pages/admin/HotWordManagement.jsx
index 7757d84..a479f46 100644
--- a/frontend/src/pages/admin/HotWordManagement.jsx
+++ b/frontend/src/pages/admin/HotWordManagement.jsx
@@ -6,12 +6,13 @@ import {
import {
PlusOutlined, SyncOutlined, DeleteOutlined, EditOutlined,
FontSizeOutlined, SearchOutlined, ReloadOutlined,
- CheckCircleOutlined, ExclamationCircleOutlined, SaveOutlined,
+ SaveOutlined,
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import configService from '../../utils/configService';
import AdminModuleShell from '../../components/AdminModuleShell';
+import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag';
const { Text } = Typography;
@@ -286,13 +287,9 @@ const HotWordManagement = () => {
{
title: '操作', key: 'action', fixed: 'right', width: 100,
render: (_, record) => (
-
-
- } onClick={() => openEditItem(record)} />
-
-
- } onClick={() => deleteItem(record)} />
-
+
+ } onClick={() => openEditItem(record)} />
+ } onClick={() => deleteItem(record)} />
),
},
@@ -300,17 +297,13 @@ const HotWordManagement = () => {
// ── Sync status rendering ──
const renderSyncStatus = (group) => {
- if (group.vocabulary_id) {
- return (
- } color="success" style={{ marginTop: 4 }}>
- 已同步
-
- );
- }
return (
- } color="warning" style={{ marginTop: 4 }}>
- 未同步
-
+
);
};
@@ -322,7 +315,7 @@ const HotWordManagement = () => {
setSelectedGroupId(group.id)}
- className={`hot-word-group-card ${isSelected ? 'hot-word-group-card-active' : ''}`}
+ className={`hot-word-group-card console-surface ${isSelected ? 'hot-word-group-card-active' : ''}`}
>
@@ -337,22 +330,10 @@ const HotWordManagement = () => {
-
e.stopPropagation()}>
-
- }
- loading={isSyncing}
- onClick={(e) => handleSync(group, e)}
- />
-
-
- } onClick={(e) => openEditGroup(group, e)} />
-
-
- } onClick={(e) => deleteGroup(group, e)} />
-
+ e.stopPropagation()}>
+ } loading={isSyncing} onClick={(e) => handleSync(group, e)} />
+ } onClick={(e) => openEditGroup(group, e)} />
+ } onClick={(e) => deleteGroup(group, e)} />
@@ -379,7 +360,7 @@ const HotWordManagement = () => {
{/* ── Left: Group list ── */}
-
} block onClick={openAddGroup}>
+
} block onClick={openAddGroup}>
新建热词组
@@ -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 }}
- />
-
-
- } onClick={openAddItem}>
- 添加热词
-
-
-
+
+
+
+ }
+ placeholder="搜索热词" onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
+ style={{ width: 220 }}
+ />
+
+
+ } onClick={openAddItem}>
+ 添加热词
+
+
+
+
{
{
title: '操作',
key: 'action',
- width: 150,
+ width: 90,
render: (_, row) => (
-
- } onClick={() => openEdit(row)}>编辑
+
+ } onClick={() => openEdit(row)} />
removeModel('llm', row.model_code)}>
- }>删除
+ } />
),
@@ -425,12 +427,12 @@ const ModelManagement = () => {
{
title: '操作',
key: 'action',
- width: 150,
+ width: 90,
render: (_, row) => (
-
- } onClick={() => openEdit(row)}>编辑
+
+ } onClick={() => openEdit(row)} />
removeModel('audio', row.model_code)}>
- }>删除
+ } />
),
diff --git a/frontend/src/pages/admin/ParameterManagement.jsx b/frontend/src/pages/admin/ParameterManagement.jsx
index 6a4294d..64f69e0 100644
--- a/frontend/src/pages/admin/ParameterManagement.jsx
+++ b/frontend/src/pages/admin/ParameterManagement.jsx
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
-import { App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag } from 'antd';
+import { App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SettingOutlined } from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import { API_ENDPOINTS, buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
+import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag';
const ParameterManagement = () => {
@@ -90,10 +91,10 @@ const ParameterManagement = () => {
{
title: '操作',
key: 'action',
- width: 180,
+ width: 90,
render: (_, row) => (
-
- } onClick={() => openEdit(row)}>编辑
+
+ } onClick={() => openEdit(row)} />
{
}
}}
>
- }>删除
+ } />
),
diff --git a/frontend/src/pages/admin/PermissionManagement.jsx b/frontend/src/pages/admin/PermissionManagement.jsx
index 24e0900..93cc689 100644
--- a/frontend/src/pages/admin/PermissionManagement.jsx
+++ b/frontend/src/pages/admin/PermissionManagement.jsx
@@ -38,6 +38,7 @@ import {
ColumnHeightOutlined,
CompressOutlined,
} from '@ant-design/icons';
+import ActionButton from '../../components/ActionButton';
import apiClient from '../../utils/apiClient';
import { buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
@@ -255,44 +256,6 @@ const PermissionManagement = () => {
fetchRoleUsers();
}, [selectedRoleId, roleUsersPage, roleUsersPageSize]);
- const toggleSelectedRolePermission = (menuId) => {
- if (!selectedRoleId) return;
-
- setPermissionsByRole((prev) => {
- const rolePerms = new Set(prev[selectedRoleId] || []);
- const hasPermission = rolePerms.has(menuId);
- const descendants = getDescendants(menuId);
-
- if (!hasPermission) {
- rolePerms.add(menuId);
- descendants.forEach((descId) => rolePerms.add(descId));
-
- let cursor = menuRelation.parentById[menuId];
- while (cursor) {
- rolePerms.add(cursor);
- cursor = menuRelation.parentById[cursor];
- }
- } else {
- rolePerms.delete(menuId);
- descendants.forEach((descId) => rolePerms.delete(descId));
-
- let cursor = menuRelation.parentById[menuId];
- while (cursor) {
- const childIds = menuRelation.childrenById[cursor] || [];
- const stillChecked = childIds.some((childId) => rolePerms.has(childId));
- if (!stillChecked) {
- rolePerms.delete(cursor);
- cursor = menuRelation.parentById[cursor];
- } else {
- break;
- }
- }
- }
-
- return { ...prev, [selectedRoleId]: Array.from(rolePerms) };
- });
- };
-
const saveSelectedRolePermissions = async () => {
if (!selectedRoleId) return;
@@ -708,16 +671,10 @@ const PermissionManagement = () => {
{role.role_name}
- }
- onClick={(e) => {
- e.stopPropagation();
- openEditRoleDrawer(role);
- }}
- />
+ } onClick={(e) => {
+ e.stopPropagation();
+ openEditRoleDrawer(role);
+ }} />
权限数 {role.menu_count || 0}
@@ -928,9 +885,11 @@ const PermissionManagement = () => {
}
extra={selectedManageMenu ? (
- } onClick={() => openCreateMenuPanel(selectedManageMenu.menu_id)}>新增子菜单
- } className="btn-soft-blue" onClick={() => openEditMenuPanel(selectedManageMenu)}>编辑
- } onClick={() => confirmDeleteMenu(selectedManageMenu)}>删除
+ } onClick={() => openCreateMenuPanel(selectedManageMenu.menu_id)}>
+ 新增子菜单
+
+ } onClick={() => openEditMenuPanel(selectedManageMenu)}>编辑
+ } onClick={() => confirmDeleteMenu(selectedManageMenu)}>删除
) : (
} type="primary" onClick={() => openCreateMenuPanel(null)}>新增一级菜单
@@ -1055,9 +1014,7 @@ const PermissionManagement = () => {
{menuPanelMode === 'view' ? (
- } onClick={() => selectedManageMenu && openEditMenuPanel(selectedManageMenu)}>
- 编辑当前菜单
-
+ } onClick={() => selectedManageMenu && openEditMenuPanel(selectedManageMenu)}>编辑当前菜单
) : (
<>
} onClick={submitMenu}>
diff --git a/frontend/src/pages/admin/PromptManagement.css b/frontend/src/pages/admin/PromptManagement.css
index 05450af..6a4684e 100644
--- a/frontend/src/pages/admin/PromptManagement.css
+++ b/frontend/src/pages/admin/PromptManagement.css
@@ -68,8 +68,8 @@
.btn-new-prompt:hover {
background: #5568d3;
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+ transform: none;
+ box-shadow: 0 3px 10px rgba(102, 126, 234, 0.24);
}
.btn-toggle-sidebar {
@@ -130,7 +130,8 @@
.prompt-list-item:hover {
background: #f8fafc;
border-color: #cbd5e1;
- transform: translateX(4px);
+ transform: none;
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.08);
}
.prompt-list-item.active {
@@ -277,8 +278,8 @@
.icon-btn-small.confirm-btn:hover:not(:disabled) {
background: #059669;
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
+ transform: none;
+ box-shadow: 0 3px 10px rgba(16, 185, 129, 0.24);
}
.icon-btn-small.cancel-btn {
@@ -335,8 +336,8 @@
.icon-btn.save-btn:hover:not(:disabled) {
background: #5568d3;
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+ transform: none;
+ box-shadow: 0 3px 10px rgba(102, 126, 234, 0.24);
}
.icon-btn.delete-btn {
@@ -611,8 +612,8 @@
.btn-primary:hover:not(:disabled) {
background-color: #5568d3;
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+ transform: none;
+ box-shadow: 0 3px 10px rgba(102, 126, 234, 0.24);
}
.btn-secondary {
diff --git a/frontend/src/pages/admin/PromptManagement.jsx b/frontend/src/pages/admin/PromptManagement.jsx
index 8232426..de1391b 100644
--- a/frontend/src/pages/admin/PromptManagement.jsx
+++ b/frontend/src/pages/admin/PromptManagement.jsx
@@ -21,6 +21,7 @@ import {
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
+import ActionButton from '../../components/ActionButton';
import MarkdownEditor from '../../components/MarkdownEditor';
import StatusTag from '../../components/StatusTag';
@@ -29,8 +30,8 @@ const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const TASK_TYPES = {
- MEETING_TASK: { label: '会议总结', icon: },
- KNOWLEDGE_TASK: { label: '知识库整合', icon: }
+ MEETING_TASK: { label: '总结模版', icon: },
+ KNOWLEDGE_TASK: { label: '知识库模版', icon: }
};
const PromptManagement = () => {
@@ -174,13 +175,13 @@ const PromptManagement = () => {
{selectedPrompt.name}
-
} onClick={() => {}} />
+
} onClick={() => {}} />
{selectedPrompt.desc || '暂无描述'}
+ } className="btn-soft-red" onClick={() => handleDelete(selectedPrompt)}>删除
} type="primary" loading={isSaving} onClick={handleSave}>保存更改
- } danger onClick={() => handleDelete(selectedPrompt)}>删除
@@ -229,8 +230,8 @@ const PromptManagement = () => {
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) => (
-
-
- } onClick={() => handleOpenModal(record)} />
-
-
- } onClick={() => handleDelete(record)} />
-
+
+ } onClick={() => handleOpenModal(record)} />
+ } onClick={() => handleDelete(record)} />
),
},
diff --git a/frontend/src/pages/admin/UserManagement.jsx b/frontend/src/pages/admin/UserManagement.jsx
index 0f15de8..7c1b76c 100644
--- a/frontend/src/pages/admin/UserManagement.jsx
+++ b/frontend/src/pages/admin/UserManagement.jsx
@@ -16,6 +16,7 @@ import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import configService from '../../utils/configService';
import AdminModuleShell from '../../components/AdminModuleShell';
+import ActionButton from '../../components/ActionButton';
const UserManagement = () => {
const { message, modal } = App.useApp();
@@ -190,16 +191,10 @@ const UserManagement = () => {
fixed: 'right',
width: 150,
render: (_, record) => (
-
-
- } onClick={() => handleOpenModal(record)} />
-
-
- } onClick={() => handleResetPassword(record)} />
-
-
- } onClick={() => handleDelete(record)} />
-
+
+ } onClick={() => handleOpenModal(record)} />
+ } onClick={() => handleResetPassword(record)} />
+ } onClick={() => handleDelete(record)} />
),
},
diff --git a/frontend/src/styles/console-theme.css b/frontend/src/styles/console-theme.css
index d4635ca..47b72dc 100644
--- a/frontend/src/styles/console-theme.css
+++ b/frontend/src/styles/console-theme.css
@@ -13,6 +13,9 @@
--ime-warning: #d97706;
--ime-danger: #c2410c;
--ime-tab-indicator-size: 3px;
+ --ime-tag-radius: 999px;
+ --ime-tag-height: 26px;
+ --ime-tag-padding-x: 10px;
}
body {
@@ -196,14 +199,15 @@ body {
background: rgba(29, 78, 216, 0.1) !important;
}
-.main-layout-header {
+.main-layout-header.ant-layout-header {
margin: 16px 16px 0;
- padding: 0 22px;
- height: 74px;
+ padding: 0 20px !important;
+ height: 74px !important;
border: 1px solid #e7eef8;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 4px 10px rgba(36, 76, 128, 0.05);
+ line-height: 74px;
}
.main-layout-page-title {
@@ -219,6 +223,11 @@ body {
color: #5f7392;
}
+.main-layout-page-path ol {
+ margin: 0;
+ padding-inline-start: 0;
+}
+
.main-layout-content {
margin: 16px;
min-height: 280px;
@@ -434,6 +443,7 @@ body {
display: flex;
align-items: center;
gap: 12px;
+ padding: 0;
}
.meeting-audio-toolbar-player {
@@ -441,44 +451,57 @@ body {
min-width: 0;
}
+.meeting-audio-toolbar-native {
+ width: 100%;
+ height: 40px;
+ border-radius: 999px;
+ background: #f5f7fb;
+}
+
+.meeting-audio-toolbar-native::-webkit-media-controls-panel {
+ background: #f5f7fb;
+}
+
.meeting-audio-toolbar-empty {
height: 40px;
display: flex;
align-items: center;
padding: 0 14px;
- border-radius: 12px;
- border: 1px solid #dbe7f5;
- background: linear-gradient(180deg, #fbfdff 0%, #f4f8fe 100%);
+ border-radius: 999px;
+ background: #f5f7fb;
}
.meeting-audio-toolbar-actions {
display: inline-flex;
align-items: center;
- gap: 4px;
+ gap: 8px;
+ padding: 0;
}
.meeting-audio-toolbar-button.ant-btn {
height: 32px;
padding: 0 10px;
- border: 1px solid #dfe7f3;
- border-radius: 10px;
- background: linear-gradient(180deg, #ffffff 0%, #f6f9fd 100%);
- color: #5a7394;
- box-shadow: none;
+ border: 1px solid #dbe3ef;
+ border-radius: 999px;
+ background: #fff;
+ color: #526581;
font-size: 12px;
+ font-weight: 600;
+ box-shadow: none;
}
.meeting-audio-toolbar-button.ant-btn:hover,
.meeting-audio-toolbar-button.ant-btn:focus {
- color: #274f87;
- border-color: #d0dcf0;
- background: linear-gradient(180deg, #ffffff 0%, #f0f6ff 100%) !important;
+ color: #355171;
+ border-color: #c7d4e4;
+ background: #f8fafc !important;
+ box-shadow: none;
}
.meeting-audio-toolbar-button.ant-btn:disabled {
color: #a1b1c4;
- border-color: #e5ebf4;
- background: linear-gradient(180deg, #fbfcfe 0%, #f6f8fb 100%) !important;
+ border-color: #e3e8f0;
+ background: #f8fafc !important;
box-shadow: none;
}
@@ -486,7 +509,7 @@ body {
display: inline-flex;
align-items: center;
gap: 5px;
- min-width: 72px;
+ min-width: 68px;
justify-content: center;
font-weight: 700;
}
@@ -501,7 +524,19 @@ body {
.meeting-audio-toolbar-more.ant-btn {
width: 32px;
+ min-width: 32px;
+ padding-inline: 0;
padding: 0;
+ border-color: #c9ddfb;
+ background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
+ color: #1d4ed8;
+}
+
+.meeting-audio-toolbar-more.ant-btn:hover,
+.meeting-audio-toolbar-more.ant-btn:focus {
+ border-color: #a9c9fa;
+ background: linear-gradient(180deg, #f2f8ff 0%, #e7f0ff 100%) !important;
+ color: #1d4ed8;
}
.meeting-audio-toolbar-more.ant-btn .anticon {
@@ -522,15 +557,15 @@ body {
}
.console-segmented.ant-segmented {
- --console-segmented-gap: 3px;
- --console-segmented-radius: 15px;
- --console-segmented-thumb-radius: 13px;
+ --console-segmented-gap: 2px;
+ --console-segmented-radius: 10px;
+ --console-segmented-thumb-radius: 9px;
padding: var(--console-segmented-gap);
- border: 1px solid #cfdef3;
+ border: 1px solid #d4e0ef;
border-radius: var(--console-segmented-radius);
overflow: hidden;
- background: linear-gradient(180deg, #f2f7ff 0%, #e8f1fe 100%);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 4px 12px rgba(61, 99, 161, 0.06);
+ background: linear-gradient(180deg, #f7faff 0%, #edf3fb 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88), 0 2px 8px rgba(61, 99, 161, 0.05);
}
.console-segmented .ant-segmented-group {
@@ -538,12 +573,12 @@ body {
}
.console-segmented .ant-segmented-item {
- min-height: 26px;
- line-height: 26px;
- padding: 0 12px;
+ min-height: 24px;
+ line-height: 24px;
+ padding: 0 10px;
border-radius: var(--console-segmented-thumb-radius);
color: #5d7291;
- font-size: 12px;
+ font-size: 11px;
font-weight: 600;
transition: color 0.2s ease, opacity 0.2s ease;
}
@@ -566,7 +601,167 @@ body {
border-radius: var(--console-segmented-thumb-radius);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
border: 1px solid #d7e4f6;
- box-shadow: 0 1px 2px rgba(25, 63, 121, 0.08), 0 4px 10px rgba(25, 63, 121, 0.08);
+ box-shadow: 0 1px 2px rgba(25, 63, 121, 0.06), 0 2px 6px rgba(25, 63, 121, 0.06);
+}
+
+.ant-tag:not(.console-status-tag) {
+ --ime-tag-text: #4a627f;
+ --ime-tag-bg-start: #ffffff;
+ --ime-tag-bg-end: #f7fbff;
+ --ime-tag-border: #d8e3f2;
+ --ime-tag-glow: rgba(29, 78, 216, 0.06);
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-inline-end: 0;
+ padding: 0 var(--ime-tag-padding-x);
+ min-height: var(--ime-tag-height);
+ line-height: calc(var(--ime-tag-height) - 2px);
+ border-radius: var(--ime-tag-radius);
+ border: 1px solid var(--ime-tag-border);
+ background: linear-gradient(180deg, var(--ime-tag-bg-start) 0%, var(--ime-tag-bg-end) 100%);
+ color: var(--ime-tag-text);
+ font-size: 12px;
+ font-weight: 600;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 4px 10px var(--ime-tag-glow);
+ transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
+}
+
+.ant-tag:not(.console-status-tag) .anticon {
+ font-size: 11px;
+}
+
+.ant-tag:not(.console-status-tag) .ant-tag-close-icon {
+ margin-inline-start: 2px;
+ color: inherit;
+ opacity: 0.55;
+ transition: opacity 0.2s ease;
+}
+
+.ant-tag:not(.console-status-tag) .ant-tag-close-icon:hover {
+ color: inherit;
+ opacity: 0.9;
+}
+
+.ant-tag:not(.console-status-tag).console-tag-large {
+ min-height: 28px;
+ padding: 0 12px;
+ line-height: 26px;
+ font-size: 13px;
+}
+
+.ant-tag:not(.console-status-tag).console-tag-compact {
+ min-height: 22px;
+ padding: 0 8px;
+ line-height: 20px;
+ font-size: 11px;
+}
+
+.ant-tag:not(.console-status-tag).console-tag-clickable {
+ cursor: pointer;
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-blue,
+.ant-tag:not(.console-status-tag).ant-tag-blue,
+.ant-tag:not(.console-status-tag).ant-tag-processing {
+ --ime-tag-text: #1d4ed8;
+ --ime-tag-bg-start: #f7fbff;
+ --ime-tag-bg-end: #e8f1ff;
+ --ime-tag-border: #bfdbfe;
+ --ime-tag-glow: rgba(29, 78, 216, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-geekblue,
+.ant-tag:not(.console-status-tag).ant-tag-geekblue {
+ --ime-tag-text: #3730a3;
+ --ime-tag-bg-start: #f7f7ff;
+ --ime-tag-bg-end: #ececff;
+ --ime-tag-border: #c7d2fe;
+ --ime-tag-glow: rgba(67, 56, 202, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-green,
+.ant-tag:not(.console-status-tag).ant-tag-green,
+.ant-tag:not(.console-status-tag).ant-tag-success {
+ --ime-tag-text: #166534;
+ --ime-tag-bg-start: #f3fff8;
+ --ime-tag-bg-end: #dcfce7;
+ --ime-tag-border: #bbf7d0;
+ --ime-tag-glow: rgba(34, 197, 94, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-orange,
+.ant-tag:not(.console-status-tag).ant-tag-orange,
+.ant-tag:not(.console-status-tag).ant-tag-warning {
+ --ime-tag-text: #b45309;
+ --ime-tag-bg-start: #fff9f0;
+ --ime-tag-bg-end: #ffedd5;
+ --ime-tag-border: #fed7aa;
+ --ime-tag-glow: rgba(245, 158, 11, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-gold,
+.ant-tag:not(.console-status-tag).ant-tag-gold {
+ --ime-tag-text: #a16207;
+ --ime-tag-bg-start: #fffdf3;
+ --ime-tag-bg-end: #fef3c7;
+ --ime-tag-border: #fde68a;
+ --ime-tag-glow: rgba(234, 179, 8, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-red,
+.ant-tag:not(.console-status-tag).ant-tag-red,
+.ant-tag:not(.console-status-tag).ant-tag-error {
+ --ime-tag-text: #b91c1c;
+ --ime-tag-bg-start: #fff7f7;
+ --ime-tag-bg-end: #fee2e2;
+ --ime-tag-border: #fecaca;
+ --ime-tag-glow: rgba(239, 68, 68, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-cyan,
+.ant-tag:not(.console-status-tag).ant-tag-cyan {
+ --ime-tag-text: #0f766e;
+ --ime-tag-bg-start: #f3fffd;
+ --ime-tag-bg-end: #ccfbf1;
+ --ime-tag-border: #99f6e4;
+ --ime-tag-glow: rgba(20, 184, 166, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-purple,
+.ant-tag:not(.console-status-tag).ant-tag-purple {
+ --ime-tag-text: #7c3aed;
+ --ime-tag-bg-start: #faf7ff;
+ --ime-tag-bg-end: #ede9fe;
+ --ime-tag-border: #ddd6fe;
+ --ime-tag-glow: rgba(124, 58, 237, 0.1);
+}
+
+.ant-tag:not(.console-status-tag).console-tag-soft-default,
+.ant-tag:not(.console-status-tag).ant-tag-default {
+ --ime-tag-text: #5d7291;
+ --ime-tag-bg-start: #ffffff;
+ --ime-tag-bg-end: #f6f9fd;
+ --ime-tag-border: #dbe5f1;
+ --ime-tag-glow: rgba(71, 85, 105, 0.06);
+}
+
+.ant-tag-checkable:not(.console-status-tag):hover {
+ color: #1d4ed8;
+ border-color: #bfd5f6;
+ background: linear-gradient(180deg, #ffffff 0%, #eff6ff 100%);
+}
+
+.ant-tag-checkable-checked:not(.console-status-tag) {
+ --ime-tag-text: #1d4ed8;
+ --ime-tag-bg-start: #f7fbff;
+ --ime-tag-bg-end: #e0efff;
+ --ime-tag-border: #bfdbfe;
+ --ime-tag-glow: rgba(29, 78, 216, 0.1);
+}
+
+.ant-tag-checkable-checked:not(.console-status-tag):hover {
+ color: #1d4ed8;
}
.console-status-tag.ant-tag {
@@ -794,34 +989,61 @@ body {
/* ── Hot-Word Group Cards ── */
.hot-word-group-card {
- padding: 10px 12px;
- border-radius: 8px;
- border: 1px solid var(--ime-line);
+ padding: 14px 16px;
+ border-radius: 18px;
cursor: pointer;
- transition: all 0.15s;
- background: var(--ime-bg-2);
+ transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.hot-word-group-card:hover {
- border-color: var(--ime-primary);
- background: var(--ime-primary-soft);
+ border-color: #bfd4f6;
+ background: rgba(248, 251, 255, 0.96);
+ box-shadow: 0 10px 22px rgba(20, 58, 112, 0.1);
}
.hot-word-group-card-active {
border-color: var(--ime-primary);
- background: var(--ime-primary-soft);
- box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.12);
+ background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
+ box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.12), 0 10px 24px rgba(29, 78, 216, 0.1);
}
-/* ── Switch pill-shape fix (global borderRadius override) ── */
+/* ── Global Switch geometry ── */
.ant-switch {
- border-radius: 100px !important;
- min-width: 40px;
+ min-width: 42px;
+ height: 24px;
+ padding: 2px;
+ border: 1px solid #cbdcf1;
+ border-radius: 999px !important;
+ background: linear-gradient(180deg, #eef4fc 0%, #dde8f7 100%) !important;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 2px 6px rgba(49, 86, 139, 0.08);
}
+
+.ant-switch.ant-switch-checked {
+ border-color: #8db5f5;
+ background: linear-gradient(180deg, #4f8cff 0%, #2f6ae6 100%) !important;
+}
+
.ant-switch-handle {
+ top: 2px !important;
+ width: 18px !important;
+ height: 18px !important;
border-radius: 50% !important;
}
+
.ant-switch .ant-switch-handle::before {
border-radius: 50% !important;
+ background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
+ box-shadow: 0 1px 3px rgba(17, 43, 78, 0.18);
}
+
.ant-switch-inner {
- border-radius: 100px !important;
+ border-radius: 999px !important;
+}
+
+.ant-switch.ant-switch-small {
+ min-width: 34px;
+ height: 20px;
+}
+
+.ant-switch.ant-switch-small .ant-switch-handle {
+ width: 14px !important;
+ height: 14px !important;
}
diff --git a/frontend/src/utils/configService.js b/frontend/src/utils/configService.js
index c80f77e..2d59dc0 100644
--- a/frontend/src/utils/configService.js
+++ b/frontend/src/utils/configService.js
@@ -4,8 +4,8 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
export const DEFAULT_BRANDING_CONFIG = {
app_name: '智听云平台',
home_headline: '智听云平台',
- home_tagline: '让每一次谈话都产生价值。',
- console_subtitle: '智能会议控制台',
+ home_tagline: '让每一次谈话都产生价值',
+ console_subtitle: 'iMeeting控制台',
preview_title: '会议预览',
login_welcome: '欢迎回来,请输入您的登录凭证。',
footer_text: '©2026 智听云平台',
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 32f1923..290dfe6 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -380,11 +380,136 @@
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
+"@esbuild/aix-ppc64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz"
+ integrity sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==
+
+"@esbuild/android-arm@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz"
+ integrity sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==
+
+"@esbuild/android-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz"
+ integrity sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==
+
+"@esbuild/android-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz"
+ integrity sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==
+
+"@esbuild/darwin-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz"
+ integrity sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==
+
+"@esbuild/darwin-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz"
+ integrity sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==
+
+"@esbuild/freebsd-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz"
+ integrity sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==
+
+"@esbuild/freebsd-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz"
+ integrity sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==
+
+"@esbuild/linux-arm@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz"
+ integrity sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==
+
+"@esbuild/linux-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz"
+ integrity sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==
+
+"@esbuild/linux-ia32@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz"
+ integrity sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==
+
+"@esbuild/linux-loong64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz"
+ integrity sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==
+
+"@esbuild/linux-mips64el@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz"
+ integrity sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==
+
+"@esbuild/linux-ppc64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz"
+ integrity sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==
+
+"@esbuild/linux-riscv64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz"
+ integrity sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==
+
+"@esbuild/linux-s390x@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz"
+ integrity sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==
+
"@esbuild/linux-x64@0.27.4":
version "0.27.4"
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz"
integrity sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==
+"@esbuild/netbsd-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz"
+ integrity sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==
+
+"@esbuild/netbsd-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz"
+ integrity sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==
+
+"@esbuild/openbsd-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz"
+ integrity sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==
+
+"@esbuild/openbsd-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz"
+ integrity sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==
+
+"@esbuild/openharmony-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz"
+ integrity sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==
+
+"@esbuild/sunos-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz"
+ integrity sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==
+
+"@esbuild/win32-arm64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz"
+ integrity sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==
+
+"@esbuild/win32-ia32@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz"
+ integrity sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==
+
+"@esbuild/win32-x64@0.27.4":
+ version "0.27.4"
+ resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz"
+ integrity sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==
+
"@eslint-community/eslint-utils@^4.2.0":
version "4.7.0"
resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz"
@@ -658,6 +783,91 @@
resolved "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz"
integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==
+"@rollup/rollup-android-arm-eabi@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz"
+ integrity sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==
+
+"@rollup/rollup-android-arm64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz"
+ integrity sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==
+
+"@rollup/rollup-darwin-arm64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz"
+ integrity sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==
+
+"@rollup/rollup-darwin-x64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz"
+ integrity sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==
+
+"@rollup/rollup-freebsd-arm64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz"
+ integrity sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==
+
+"@rollup/rollup-freebsd-x64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz"
+ integrity sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz"
+ integrity sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==
+
+"@rollup/rollup-linux-arm-musleabihf@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz"
+ integrity sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==
+
+"@rollup/rollup-linux-arm64-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz"
+ integrity sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==
+
+"@rollup/rollup-linux-arm64-musl@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz"
+ integrity sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==
+
+"@rollup/rollup-linux-loong64-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz"
+ integrity sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==
+
+"@rollup/rollup-linux-loong64-musl@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz"
+ integrity sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==
+
+"@rollup/rollup-linux-ppc64-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz"
+ integrity sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==
+
+"@rollup/rollup-linux-ppc64-musl@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz"
+ integrity sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==
+
+"@rollup/rollup-linux-riscv64-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz"
+ integrity sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==
+
+"@rollup/rollup-linux-riscv64-musl@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz"
+ integrity sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==
+
+"@rollup/rollup-linux-s390x-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz"
+ integrity sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==
+
"@rollup/rollup-linux-x64-gnu@4.60.0":
version "4.60.0"
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz"
@@ -668,6 +878,36 @@
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz"
integrity sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==
+"@rollup/rollup-openbsd-x64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz"
+ integrity sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==
+
+"@rollup/rollup-openharmony-arm64@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz"
+ integrity sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==
+
+"@rollup/rollup-win32-arm64-msvc@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz"
+ integrity sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==
+
+"@rollup/rollup-win32-ia32-msvc@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz"
+ integrity sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==
+
+"@rollup/rollup-win32-x64-gnu@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz"
+ integrity sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==
+
+"@rollup/rollup-win32-x64-msvc@4.60.0":
+ version "4.60.0"
+ resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz"
+ integrity sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==
+
"@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -1891,6 +2131,11 @@ form-data@^4.0.5:
hasown "^2.0.2"
mime-types "^2.1.12"
+fsevents@~2.3.2, fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"