Compare commits

...

3 Commits

Author SHA1 Message Date
mula.liu ac9c2f5fd4 1.1.1 2026-04-04 00:25:53 +08:00
mula.liu cec4f98d42 Merge remote-tracking branch 'origin/alan-dev' into codex/dev 2026-04-03 15:09:22 +08:00
mula.liu df61cd870d add migration script 2026-04-03 15:00:06 +08:00
49 changed files with 4033 additions and 1222 deletions

View File

@ -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(

View File

@ -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 智听云平台"),

View File

@ -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

View File

@ -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;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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';
<ActionButton
tone="edit"
variant="iconSm"
tooltip="编辑"
icon={<EditOutlined />}
onClick={handleEdit}
/>
```
```jsx
<ActionButton
tone="delete"
variant="textLg"
icon={<DeleteOutlined />}
onClick={handleDelete}
>
删除
</ActionButton>
```
```jsx
<ActionButton
tone="view"
variant="iconLg"
tooltip="复制密码"
icon={<CopyOutlined />}
onClick={handleCopy}
/>
```
```jsx
<Space>
<Button className="btn-soft-red" icon={<DeleteOutlined />} onClick={handleDelete}>
删除
</Button>
<Button icon={<CloseOutlined />} onClick={handleCancel}>
取消
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
保存
</Button>
</Space>
```
## 落地约束
1. 新增按钮时,先判断是不是业务动作还是表单提交动作。
- 业务动作优先使用 `ActionButton`
- 表单操作条优先使用标准 `Button`
2. 新页面开发时,先确定场景,再选尺寸档。
不要先凭感觉挑样式。
3. 旧页面调整时,如果涉及编辑、删除、查看、更多、复制、下载等业务动作,顺手迁移到 `ActionButton`
4. 如果删除按钮和取消、保存并排出现,优先按表单操作条处理,不要单独给删除套胶囊业务按钮。
## 现状说明
当前平台中的主要业务操作按钮已基本迁移到 `ActionButton`,主要表单操作条也开始统一为 `删除 / 取消 / 保存` 的标准按钮组合。
未纳入本规范的主要是两类控件:
- Markdown 编辑器工具栏按钮
- 会议详情中的音频工具栏按钮
这两类属于功能控件,不按业务操作按钮规范处理。

View File

@ -0,0 +1,129 @@
# iMeeting `imeeting_qy` 数据库升级报告
## 1. 升级目标
- 源旧库:`imeeting_qy`
- 对标最新结构库:`imeeting`
- 升级日期:`2026-04-03`
- 执行方式:先在临时测试库 `imeeting_qy_upgrade_test` 演练,再正式执行到 `imeeting_qy`
## 2. 备份信息
- 正式升级前已创建完整备份库:`imeeting_qy_backup_20260403_004354`
- 备份方式:同服务器整库复制全部 `BASE TABLE`
## 3. 本次执行内容
本次升级使用迁移脚本:
- `backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql`
核心动作如下:
1. 将旧系统表标准化为 `sys_*` 体系:
- `users -> sys_users`
- `roles -> sys_roles`
- `menus -> sys_menus`
- `role_menu_permissions -> sys_role_menu_permissions`
- `dict_data -> sys_dict_data`
2. 重建兼容视图:
- `users`
- `roles`
- `menus`
- `role_menu_permissions`
- `dict_data`
- `system_parameters`
3. 对齐旧系统表字段与索引:
- `sys_users` 补齐 `idx_role_id`
- `sys_roles` 补齐 `uk_role_name`
- `sys_menus` 补齐 `menu_level/tree_path/is_visible` 及相关索引
- `sys_role_menu_permissions` 补齐 `granted_by/granted_at` 及相关索引
- `prompts` 补齐 `is_system` 字段及组合索引
- `terminals.current_user_id` 字段注释对齐到最新结构
4. 新增并初始化最新配置表:
- `sys_system_parameters`
- `ai_model_configs`
- `llm_model_config`
- `audio_model_config`
- `hot_word_group`
- `hot_word_item`
- `prompt_config`
- `sys_user_mcp`
5. 迁移旧配置数据:
- 从 `sys_dict_data(dict_type='system_config')` 迁移系统参数到 `sys_system_parameters`
- 迁移 LLM / ASR / 声纹配置到 `ai_model_configs`
- 拆分生成 `llm_model_config`、`audio_model_config`
- 从旧 `hot_words` 迁移到 `hot_word_group` / `hot_word_item`
6. 重建最新菜单树与角色授权模型:
- 新增 `dashboard`、`desktop`、`meeting_manage`、`system_management` 等最新菜单结构
- 规范平台管理、系统管理、会议管理三套菜单层级
- 管理员角色授予全部启用菜单
- 普通用户保留 `desktop/meeting_manage/meeting_center/prompt_config`
## 4. 升级结果
升级后关键表数据如下:
| 表名 | 行数 |
|---|---:|
| `sys_users` | 44 |
| `sys_roles` | 2 |
| `sys_menus` | 19 |
| `sys_role_menu_permissions` | 22 |
| `sys_system_parameters` | 4 |
| `ai_model_configs` | 3 |
| `llm_model_config` | 1 |
| `audio_model_config` | 2 |
| `hot_word_group` | 1 |
| `hot_word_item` | 20 |
| `prompt_config` | 0 |
| `sys_user_mcp` | 0 |
迁移后的系统参数:
| 参数键 | 参数值 |
|---|---|
| `asr_vocabulary_id` | `vocab-imeeting-734e93f5bd8a4f3bb665dd526d584516` |
| `default_reset_password` | `123456` |
| `max_audio_size` | `500` |
| `timeline_pagesize` | `20` |
迁移后的模型配置:
- `llm_model_config`1 条默认模型,`model_code=llm_model`
- `audio_model_config`2 条配置
- `audio_model` / `asr`
- `voiceprint_model` / `voiceprint`
迁移后的热词配置:
- `hot_word_group`1 个默认热词组
- `hot_word_item`20 条热词条目
## 5. 角色菜单结果
- 平台管理员:
- `dashboard, hot_word_management, user_management, meeting_center, desktop, meeting_manage, model_management, permission_management, prompt_config, prompt_management, platform_admin, dict_management, system_management, client_management, external_app_management, terminal_management, parameter_management, permission_menu_tree`
- 普通用户:
- `meeting_center, desktop, meeting_manage, prompt_config`
## 6. 结构校验结论
`imeeting_qy``imeeting` 进行了 `information_schema.tables` + `information_schema.columns` 级别的最终校验,结果如下:
- 缺失表:`0`
- 多余表:`0`
- 表类型差异:`0`
- 字段差异:`0`
结论:
- `imeeting_qy` 已完成升级
- 当前库结构已与 `imeeting` 对齐
- 本次升级为“结构对齐 + 必要配置数据迁移”,未删除旧业务数据

View File

@ -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 {

View File

@ -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 = (
<Button
type="text"
size={resolvedSize}
className={mergedClassName}
{...buttonProps}
>
{children}
</Button>
);
if (!tooltip) {
return buttonNode;
}
return (
<Tooltip title={tooltip} {...tooltipProps}>
<span style={{ display: 'inline-flex' }}>{buttonNode}</span>
</Tooltip>
);
};
export default ActionButton;

View File

@ -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;
}
}

View File

@ -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 ? <Text className="timeline-date-sub">{dateMeta.sub}</Text> : null}
</div>
),
dot: <span className="timeline-date-dot" />,
children: (
<div className="timeline-date-group">
{meetingsByDate[date].map((meeting) => {
const isCreator = String(meeting.creator_id) === String(currentUser.user_id);
const menuItems = [
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: ({ domEvent }) => handleEditClick(domEvent, meeting.meeting_id) },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, 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 (
<Card
key={meeting.meeting_id}
hoverable
className="timeline-meeting-card"
style={{ borderLeft: isCreator ? '4px solid #1677ff' : '4px solid #52c41a' }}
variant="borderless"
className="timeline-meeting-card console-surface shared-meeting-card"
onClick={() => navigate(`/meetings/${meeting.meeting_id}`, {
state: { filterContext: { filterType, searchQuery, selectedTags } },
})}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 14, gap: 16 }}>
<Space direction="vertical" size={6} style={{ flex: 1 }}>
<Title level={4} style={{ margin: 0, fontSize: 20 }}>{meeting.title}</Title>
<Space size={12} split={<Divider type="vertical" />} wrap>
<Text type="secondary"><ClockCircleOutlined /> {tools.formatTime(meeting.meeting_time)}</Text>
<Text type="secondary"><TeamOutlined /> {meeting.attendees?.length || 0} </Text>
<Space size={[6, 6]} wrap>
{meeting.tags?.slice(0, 4).map((tag) => (
<Tag key={tag.id} color="blue" bordered={false} style={{ fontSize: 12, borderRadius: 999 }}>
{tag.name}
</Tag>
))}
<div className="shared-meeting-card-shell">
<div className="shared-meeting-card-top">
<Space size={10} wrap>
<Tag bordered={false} className={`${roleMeta.tagClass} console-tag-large`}>
{roleMeta.label}
</Tag>
<Tag bordered={false} className={`${statusMeta.tagClass} console-tag-large`}>
{statusMeta.label}
</Tag>
</Space>
{isCreator ? (
<Space size={8}>
<ActionButton
tone="edit"
variant="iconSm"
tooltip="编辑"
icon={<EditOutlined />}
onClick={(event) => handleEditClick(event, meeting.meeting_id)}
/>
<ActionButton
tone="delete"
variant="iconSm"
tooltip="删除"
icon={<DeleteOutlined />}
onClick={(event) => handleDeleteClick(event, meeting)}
/>
</Space>
</Space>
</Space>
{isCreator ? (
<Dropdown menu={{ items: menuItems }} placement="bottomRight" arrow trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} className="timeline-action-trigger" onClick={(event) => event.stopPropagation()} />
</Dropdown>
) : null}
</div>
{meeting.summary ? (
<div className="timeline-summary-box">
<Space size={8} style={{ marginBottom: 8, display: 'flex' }}>
<FileTextOutlined style={{ color: '#1677ff' }} />
<Text strong>会议摘要</Text>
</Space>
<div className="timeline-summary-content">
<Paragraph ellipsis={{ rows: 2 }} type="secondary" style={{ margin: 0, fontSize: 13 }}>
<MarkdownRenderer content={tools.truncateSummary(meeting.summary)} />
</Paragraph>
</div>
) : null}
</div>
) : null}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Space>
<Avatar size="small" src={meeting.creator_avatar_url} icon={<UserOutlined />} />
<Text type="secondary" style={{ fontSize: 12 }}>{meeting.creator_username}</Text>
</Space>
<Button type="text" size="small" icon={<ArrowRightOutlined />} className="timeline-footer-link">
查看详情
</Button>
<div className="shared-meeting-card-title-block">
<Paragraph
className="shared-meeting-card-title"
ellipsis={{ rows: 2, tooltip: meeting.title }}
>
{meeting.title}
</Paragraph>
</div>
<div className={`shared-meeting-card-summary ${hasSummary ? '' : 'is-empty'}`}>
<Paragraph
className="shared-meeting-card-summary-content"
ellipsis={hasSummary ? { rows: 2, tooltip: summaryPreview } : false}
>
{hasSummary ? summaryPreview : '暂无摘要'}
</Paragraph>
</div>
<div className="shared-meeting-card-meta">
<Space size={10}>
<ClockCircleOutlined className="shared-meeting-card-meta-icon" />
<Text className="shared-meeting-card-meta-text">
{tools.formatTime(meeting.meeting_time || meeting.created_at)}
</Text>
</Space>
<Divider type="vertical" />
<Space size={10}>
<TeamOutlined className="shared-meeting-card-meta-icon" />
<Text className="shared-meeting-card-meta-text">{meeting.attendees?.length || 0} </Text>
</Space>
</div>
<div className="shared-meeting-card-footer">
<Space>
<Avatar size="small" src={meeting.creator_avatar_url} icon={<UserOutlined />} />
<Text type="secondary" className="shared-meeting-card-footer-user">{meeting.creator_username}</Text>
</Space>
<ActionButton tone="view" variant="textSm" icon={<ArrowRightOutlined />} className="shared-meeting-card-footer-link">
查看详情
</ActionButton>
</div>
</div>
</Card>
);
@ -157,15 +215,15 @@ const MeetingTimeline = ({
return (
<div className="modern-timeline">
<Timeline mode="left" items={timelineItems} />
<div style={{ textAlign: 'center', marginTop: 28 }}>
{hasMore ? (
<Button onClick={onLoadMore} loading={loadingMore} icon={<CalendarOutlined />}>
加载更多
</Button>
) : (
<Divider plain><Text type="secondary">已加载全部会议</Text></Divider>
)}
</div>
<div style={{ textAlign: 'center', marginTop: 28 }}>
{hasMore ? (
<Button className="btn-pill-secondary" onClick={onLoadMore} loading={loadingMore} icon={<CalendarOutlined />}>
加载更多
</Button>
) : (
<Divider plain><Text type="secondary">已加载全部会议</Text></Divider>
)}
</div>
</div>
);
};

View File

@ -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={[
<Button key="copy" icon={<CopyOutlined />} onClick={handleCopy}>复制链接</Button>,
<ActionButton key="copy" tone="view" variant="textLg" icon={<CopyOutlined />} onClick={handleCopy}>复制链接</ActionButton>,
<Button key="close" type="primary" icon={<CheckOutlined />} className="btn-soft-green" onClick={onClose}>完成</Button>
]}
width={400}

View File

@ -33,7 +33,7 @@ const TagCloud = ({
}
};
const handleTagClick = (tag, checked) => {
const handleTagClick = (tag) => {
if (onTagClick) {
onTagClick(tag.name);
}
@ -66,13 +66,8 @@ const TagCloud = ({
<CheckableTag
key={tag.id}
checked={selectedTags.includes(tag.name)}
onChange={(checked) => handleTagClick(tag, checked)}
style={{
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '2px 10px',
fontSize: '13px'
}}
onChange={() => handleTagClick(tag)}
className="console-tag-large"
>
{tag.name}
</CheckableTag>
@ -89,7 +84,6 @@ const TagCloud = ({
closable
color="blue"
onClose={() => onTagClick(tag)}
style={{ borderRadius: 4 }}
>
{tag}
</Tag>

View File

@ -18,13 +18,18 @@ const TagDisplay = ({
<Space size={4} wrap className={className}>
{showIcon && <TagOutlined style={{ color: '#8c8c8c', fontSize: size === 'small' ? 12 : 14 }} />}
{displayTags.map((tag, index) => (
<Tag key={index} color="blue" bordered={false} style={{ margin: 0, fontSize: size === 'small' ? 11 : 12 }}>
<Tag
key={index}
color="blue"
bordered={false}
className={size === 'small' ? 'console-tag-compact' : ''}
>
{tag}
</Tag>
))}
{remainingCount > 0 && (
<Tooltip title={tags.slice(maxDisplay).join('、')}>
<Tag bordered={false} style={{ margin: 0, fontSize: size === 'small' ? 11 : 12 }}>
<Tag bordered={false} className={size === 'small' ? 'console-tag-compact' : ''}>
+{remainingCount}
</Tag>
</Tooltip>

View File

@ -39,7 +39,6 @@ const TagEditor = ({ tags = [], onTagsChange }) => {
closable
onClose={() => handleClose(tag)}
color="blue"
style={{ borderRadius: 4 }}
>
{tag}
</Tag>

View File

@ -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 }) => {
<Text className="account-settings-mcp-field-label">X-BOT-ID</Text>
<div className="account-settings-mcp-field-value">{mcpConfig?.bot_id || '-'}</div>
<div style={{ marginTop: 16 }}>
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(mcpConfig?.bot_id, 'Bot ID')}>
复制 Bot ID
</Button>
<ActionButton tone="view" variant="textLg" icon={<CopyOutlined />} onClick={() => copyToClipboard(mcpConfig?.bot_id, 'Bot ID')}>复制 Bot ID</ActionButton>
</div>
</Card>
</Col>
@ -402,9 +401,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
<Text className="account-settings-mcp-field-label">X-BOT-SECRET</Text>
<div className="account-settings-mcp-field-value">{mcpConfig?.bot_secret || '-'}</div>
<Space style={{ marginTop: 16 }} wrap>
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(mcpConfig?.bot_secret, 'Secret')}>
复制 Secret
</Button>
<ActionButton tone="view" variant="textLg" icon={<CopyOutlined />} onClick={() => copyToClipboard(mcpConfig?.bot_secret, 'Secret')}>复制 Secret</ActionButton>
<Text className="account-settings-muted">变更后旧 Secret 立即失效</Text>
</Space>
</Card>

View File

@ -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) => (
<Button type="text" danger className="btn-text-delete" icon={<UserDeleteOutlined />} onClick={() => handleKickUser(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="踢出会话" icon={<UserDeleteOutlined />} onClick={() => handleKickUser(record)} />
),
},
];
@ -281,11 +282,9 @@ const AdminDashboard = () => {
key: 'meeting_title',
render: (text, record) => (
<Space>
{record.meeting_id && (
<Tooltip title="查看详情">
<Button type="link" size="small" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} />
</Tooltip>
)}
{record.meeting_id ? (
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} />
) : null}
<span>{text || '-'}</span>
</Space>
),
@ -517,9 +516,9 @@ const AdminDashboard = () => {
: '无时长信息'}
</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
下载转录结果 (JSON)
</Button>
</ActionButton>
</Descriptions.Item>
</Descriptions>
) : (

View File

@ -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) => (
<Space size="middle">
<Tooltip title="编辑">
<Button type="text" className="btn-text-edit" icon={<EditOutlined />} onClick={() => openModal(record)} />
</Tooltip>
<Tooltip title="下载">
<Button type="text" className="btn-text-view" icon={<DownloadOutlined />} href={record.download_url} target="_blank" />
</Tooltip>
<Tooltip title="删除">
<Button type="text" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Tooltip>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openModal(record)} />
<ActionButton tone="view" variant="iconSm" tooltip="下载" icon={<DownloadOutlined />} href={record.download_url} target="_blank" />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Space>
),
},

View File

@ -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;
}
}
}

View File

@ -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 {

View File

@ -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 (
<div className="dashboard-v3">
<Card variant="borderless" className="dashboard-hero-card" style={{ marginBottom: 24 }}>
<Card variant="borderless" className="console-surface dashboard-hero-card" style={{ marginBottom: 16 }}>
<Row gutter={[28, 24]} align="middle">
<Col xs={24} xl={8}>
<div className="dashboard-user-block">
@ -284,7 +285,7 @@ const Dashboard = ({ user }) => {
<Tag
color={voiceprintStatus?.has_voiceprint ? 'success' : 'default'}
icon={<AudioOutlined />}
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 }) => {
</Row>
</Card>
<Card variant="borderless" className="dashboard-main-card" styles={{ body: { padding: 0 } }}>
<Card variant="borderless" className="console-surface dashboard-main-card" styles={{ body: { padding: 0 } }}>
<div className="dashboard-toolbar">
<div className="dashboard-search-row">
<Input
@ -340,22 +341,22 @@ const Dashboard = ({ user }) => {
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={() => setSearchQuery(searchInput.trim())}
/>
<Tooltip title="搜索会议">
<Button
icon={<SearchOutlined />}
size="large"
onClick={() => setSearchQuery(searchInput.trim())}
className="dashboard-icon-button"
/>
</Tooltip>
<Tooltip title={selectedTags.length ? `标签过滤(已选 ${selectedTags.length}` : '标签过滤'}>
<Button
icon={<FilterOutlined />}
size="large"
onClick={() => setShowTagFilters((prev) => !prev)}
className={`dashboard-icon-button ${showTagFilters ? 'active' : ''}`}
/>
</Tooltip>
<ActionButton
tone="neutral"
variant="iconLg"
tooltip="搜索会议"
icon={<SearchOutlined />}
onClick={() => setSearchQuery(searchInput.trim())}
className="dashboard-icon-button"
/>
<ActionButton
tone={showTagFilters ? 'view' : 'neutral'}
variant="iconLg"
tooltip={selectedTags.length ? `标签过滤(已选 ${selectedTags.length}` : '标签过滤'}
icon={<FilterOutlined />}
onClick={() => setShowTagFilters((prev) => !prev)}
className={`dashboard-icon-button ${showTagFilters ? 'active' : ''}`}
/>
<div className="dashboard-toolbar-actions">
<Tooltip title="新建会议">
@ -363,7 +364,7 @@ const Dashboard = ({ user }) => {
type="primary"
size="large"
icon={<PlusOutlined />}
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 }) => {
<Text type="secondary">点击标签可快速筛选会议时间轴</Text>
</Space>
{selectedTags.length > 0 ? (
<Button type="link" icon={<ClearOutlined />} onClick={() => setSelectedTags([])}>清空已选</Button>
<ActionButton tone="neutral" variant="textSm" icon={<ClearOutlined />} onClick={() => setSelectedTags([])}>清空已选</ActionButton>
) : null}
</div>
<TagCloud
@ -419,8 +420,9 @@ const Dashboard = ({ user }) => {
<Space direction="vertical">
<Text type="secondary">未找到符合条件的会议记录</Text>
{(searchQuery || selectedTags.length > 0 || filterType !== 'all') && (
<Button
type="link"
<ActionButton
tone="neutral"
variant="textLg"
icon={<CloseCircleOutlined />}
onClick={() => {
setSearchInput('');
@ -431,7 +433,7 @@ const Dashboard = ({ user }) => {
}}
>
清除所有过滤
</Button>
</ActionButton>
)}
</Space>
)}

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

@ -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'
}}>
<div style={{ marginBottom: 80 }}>
<BrandLogo title={branding.app_name} size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
</div>
<div style={{ maxWidth: 640 }}>
<div style={{ marginBottom: 64 }}>
<BrandLogo title={branding.app_name} size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
</div>
<div style={{ maxWidth: 600 }}>
<Title style={{ fontSize: 56, marginBottom: 24, fontWeight: 800 }}>
<span style={{ color: '#1677ff' }}>{branding.home_headline}</span>
</Title>
<Paragraph style={{ fontSize: 20, color: '#4e5969', lineHeight: 1.6 }}>
{branding.home_tagline}
</Paragraph>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '8px 16px',
borderRadius: 999,
marginBottom: 24,
border: '1px solid rgba(37, 99, 235, 0.14)',
background: 'rgba(255, 255, 255, 0.55)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.75)',
}}
>
<Text style={{ color: '#47658d', fontSize: 13, fontWeight: 700, letterSpacing: '0.08em' }}>
INTELLIGENT MEETING WORKSPACE
</Text>
</div>
<div style={{ maxWidth: 600 }}>
<div
style={{
marginBottom: 24,
display: 'flex',
alignItems: 'baseline',
gap: 18,
flexWrap: 'wrap',
}}
>
<Title
level={2}
style={{
margin: 0,
fontSize: 38,
lineHeight: 1.05,
fontWeight: 700,
letterSpacing: '0.02em',
color: '#365b8d',
}}
>
iMeeting
</Title>
<Title
level={1}
style={{
margin: 0,
fontSize: 48,
lineHeight: 1.04,
fontWeight: 800,
letterSpacing: '-0.03em',
color: '#1677ff',
}}
>
{branding.home_headline}
</Title>
</div>
<Paragraph
style={{
fontSize: 22,
color: '#334e73',
lineHeight: 1.65,
marginBottom: 18,
maxWidth: 560,
}}
>
{branding.home_tagline}
</Paragraph>
<Paragraph
style={{
fontSize: 16,
color: '#5a718f',
lineHeight: 1.9,
marginBottom: 28,
maxWidth: 560,
}}
>
</Paragraph>
<Space size={[10, 12]} wrap>
{BRAND_HIGHLIGHTS.map((item) => (
<span
key={item}
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '9px 14px',
borderRadius: 999,
border: '1px solid rgba(113, 140, 178, 0.18)',
background: 'rgba(255, 255, 255, 0.62)',
color: '#4d678b',
fontSize: 14,
fontWeight: 600,
boxShadow: '0 8px 18px rgba(40, 72, 120, 0.06)',
}}
>
{item}
</span>
))}
</Space>
</div>
</div>
</Col>

View File

@ -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 }) => {
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text strong ellipsis style={{ width: '80%' }}>{item.title}</Text>
{selectedKb?.kb_id === item.kb_id && (
<Space>
<EditOutlined onClick={(e) => { e.stopPropagation(); navigate(`/knowledge-base/edit/${item.kb_id}`); }} />
<DeleteOutlined onClick={(e) => { e.stopPropagation(); handleDelete(item); }} style={{ color: '#ff4d4f' }} />
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); navigate(`/knowledge-base/edit/${item.kb_id}`); }} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={(e) => { e.stopPropagation(); handleDelete(item); }} />
</Space>
)}
</div>

View File

@ -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;
}
}

View File

@ -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 (
<div
style={{
minHeight: 'calc(100vh - 128px)',
padding: '10px 0 28px',
}}
>
<div
style={{
background: '#f3f6fa',
borderRadius: 28,
padding: 24,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.9)',
}}
<div className="meeting-center-page">
<Card
className="console-surface"
variant="borderless"
style={{ marginBottom: 16 }}
styles={{ body: { padding: '18px 20px' } }}
>
<Card
variant="borderless"
style={{
borderRadius: 20,
marginBottom: 22,
boxShadow: '0 8px 30px rgba(40, 72, 120, 0.08)',
}}
styles={{ body: { padding: '16px 20px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
<Space size={14} align="center">
<span
style={{
width: 6,
height: 28,
borderRadius: 999,
background: 'linear-gradient(180deg, #4ea1ff 0%, #1677ff 100%)',
display: 'inline-block',
}}
/>
<Title level={3} style={{ margin: 0, fontSize: 32 }}>会议中心</Title>
</Space>
<Space size={16} wrap>
<Segmented
className="console-segmented"
value={filterType}
onChange={(value) => {
setFilterType(value);
setPage(1);
}}
options={FILTER_OPTIONS}
/>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
onPressEnter={() => {
setKeyword(searchValue.trim());
setPage(1);
}}
placeholder="搜索会议标题"
prefix={<SearchOutlined style={{ color: '#9aa8b6' }} />}
allowClear
style={{ width: 220, borderRadius: 12 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setEditingMeetingId(null); setFormDrawerOpen(true); }}
style={{ borderRadius: 12, height: 40, boxShadow: '0 10px 18px rgba(22, 119, 255, 0.2)' }}
>
新建会议
</Button>
</Space>
<Space className="meeting-center-header" align="start">
<div>
<Title level={3} style={{ margin: 0 }}>会议中心</Title>
<Text type="secondary">集中查看筛选和管理全部会议记录</Text>
</div>
</Card>
<div
style={{
minHeight: 520,
background: '#f7fafc',
borderRadius: 24,
padding: 4,
}}
>
{meetings.length ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: 22,
alignItems: 'start',
<Space className="meeting-center-header-actions" wrap>
<Segmented
className="console-segmented"
value={filterType}
onChange={(value) => {
setFilterType(value);
setPage(1);
}}
options={FILTER_OPTIONS}
/>
<Input.Search
className="meeting-center-search-input"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
onSearch={(value) => {
setSearchValue(value);
setKeyword(value.trim());
setPage(1);
}}
placeholder="搜索会议标题"
allowClear
/>
<Button
type="primary"
icon={<PlusOutlined />}
className="btn-pill-primary"
onClick={() => {
setEditingMeetingId(null);
setFormDrawerOpen(true);
}}
>
新建会议
</Button>
</Space>
</Space>
</Card>
<Card
className="console-surface meeting-center-content-panel"
variant="borderless"
styles={{ body: { padding: '20px' } }}
>
{meetings.length ? (
<>
<Row gutter={[16, 16]}>
{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 (
<Card
key={meeting.meeting_id}
variant="borderless"
hoverable
onClick={() => 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 } }}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 5,
background: statusMeta.accent,
}}
/>
<div style={{ padding: '20px 20px 18px 28px', display: 'flex', flexDirection: 'column', height: 240 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 14 }}>
<Tag
style={{
marginInlineEnd: 0,
color: statusMeta.tagColor,
background: statusMeta.tagBg,
border: `1px solid ${statusMeta.tagBorder}`,
borderRadius: 8,
paddingInline: 14,
lineHeight: '24px',
fontWeight: 600,
}}
>
{statusMeta.label}
</Tag>
{isCreator ? (
<Space size={10}>
<Tooltip title="编辑">
<Button
shape="circle"
className="btn-icon-soft-blue"
<Col key={meeting.meeting_id} xs={24} md={12} xl={8}>
<Card
variant="borderless"
className="meeting-center-card console-surface shared-meeting-card"
hoverable
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
>
<div className="shared-meeting-card-shell">
<div className="shared-meeting-card-top">
<Space size={[8, 8]} wrap>
<Tag bordered={false} className={`${roleMeta.tagClass} console-tag-large`}>
{roleMeta.label}
</Tag>
<Tag bordered={false} className={`${statusMeta.tagClass} console-tag-large`}>
{statusMeta.label}
</Tag>
</Space>
{isCreator ? (
<Space size={8}>
<ActionButton
tone="edit"
variant="iconSm"
tooltip="编辑"
icon={<EditOutlined />}
onClick={(event) => {
event.stopPropagation();
@ -304,77 +277,90 @@ const MeetingCenterPage = ({ user }) => {
setFormDrawerOpen(true);
}}
/>
</Tooltip>
<Tooltip title="删除">
<Button
shape="circle"
className="btn-icon-soft-red"
<ActionButton
tone="delete"
variant="iconSm"
tooltip="删除"
icon={<DeleteOutlined />}
onClick={(event) => {
event.stopPropagation();
handleDeleteMeeting(meeting);
}}
/>
</Tooltip>
</Space>
) : null}
</div>
</Space>
) : null}
</div>
<div style={{ minHeight: 78, marginBottom: 22 }}>
<Paragraph
style={{ margin: 0, fontSize: 30, lineHeight: 1.3, fontWeight: 700, color: '#0f172a' }}
ellipsis={{ rows: 2, tooltip: meeting.title }}
>
{meeting.title}
</Paragraph>
</div>
<Space direction="vertical" size={14} style={{ color: '#6b7a90' }}>
<Space size={10}>
<CalendarOutlined />
<Text style={{ color: '#6b7a90' }}>{formatMeetingTime(meeting.meeting_time || meeting.created_at)}</Text>
</Space>
<Space size={10} style={{ width: '100%', alignItems: 'flex-start' }}>
<TeamOutlined />
<div className="shared-meeting-card-title-block">
<Paragraph
style={{ margin: 0, color: '#6b7a90', flex: 1 }}
ellipsis={{ rows: 1, tooltip: meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '无参与人员' }}
className="shared-meeting-card-title"
ellipsis={{ rows: 2, tooltip: meeting.title }}
>
{meeting.attendees?.length ? `${meeting.attendees.map((item) => item.caption).join('、')}` : '无参与人员'}
{meeting.title}
</Paragraph>
</Space>
</Space>
</div>
<div style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="text"
icon={<RightOutlined />}
onClick={(event) => {
event.stopPropagation();
navigate(`/meetings/${meeting.meeting_id}`);
}}
style={{ color: '#b7c0cd' }}
/>
<div className={`shared-meeting-card-summary ${hasSummary ? '' : 'is-empty'}`}>
<Paragraph
className="shared-meeting-card-summary-content"
ellipsis={hasSummary ? { rows: 2, tooltip: summaryPreview } : false}
>
{hasSummary ? summaryPreview : '暂无摘要'}
</Paragraph>
</div>
<div className="shared-meeting-card-meta">
<Space size={10}>
<ClockCircleOutlined className="shared-meeting-card-meta-icon" />
<Text className="shared-meeting-card-meta-text">
{tools.formatTime(meeting.meeting_time || meeting.created_at)}
</Text>
</Space>
<Divider type="vertical" />
<Space size={10}>
<TeamOutlined className="shared-meeting-card-meta-icon" />
<Text className="shared-meeting-card-meta-text">
{meeting.attendees?.length || 0}
</Text>
</Space>
</div>
<div className="shared-meeting-card-footer">
<Space>
<Avatar size="small" src={meeting.creator_avatar_url} icon={<UserOutlined />} />
<Text type="secondary" className="shared-meeting-card-footer-user">
{meeting.creator_username || '未知'}
</Text>
</Space>
<ActionButton
tone="view"
variant="textSm"
icon={<ArrowRightOutlined />}
className="shared-meeting-card-footer-link"
>
查看详情
</ActionButton>
</div>
</div>
</div>
</Card>
</Card>
</Col>
);
})}
</div>
) : (
<div style={{ minHeight: 460, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description={loading ? '加载中...' : '暂无会议'} />
</div>
)}
</Row>
<CenterPager
current={page}
total={total}
pageSize={pageSize}
onChange={setPage}
/>
</div>
</div>
<CenterPager
current={page}
total={total}
pageSize={pageSize}
onChange={setPage}
/>
</>
) : (
<div className="meeting-center-empty">
<Empty description={loading ? '加载中...' : '暂无会议'} />
</div>
)}
</Card>
<MeetingFormDrawer
open={formDrawerOpen}
@ -382,10 +368,11 @@ const MeetingCenterPage = ({ user }) => {
meetingId={editingMeetingId}
user={user}
onSuccess={(newMeetingId) => {
meetingCacheService.clearAll();
if (newMeetingId) {
navigate(`/meetings/${newMeetingId}`);
} else {
loadMeetings(page, keyword, filterType);
loadMeetings(page, keyword, filterType, { forceRefresh: true });
}
}}
/>

File diff suppressed because it is too large Load Diff

View File

@ -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) {

View File

@ -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 = () => {
<div className="meeting-info-section">
<h2 className="section-title">会议信息</h2>
<div className="info-item"><strong>创建人</strong>{meeting.creator_username || '未知'}</div>
<div className="info-item"><strong>会议时间</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
<div className="info-item"><strong>参会人员</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
<div className="info-item"><strong>计算人数</strong>{meeting.attendees_count || meeting.attendees?.length || 0}</div>
<div className="info-item"><strong>总结模板</strong>{meeting.prompt_name || '默认模板'}</div>
{meeting.tags?.length ? (
<div className="info-item">
<strong>标签</strong>
<Space wrap style={{ marginLeft: 8 }}>
{meeting.tags.map((tag) => <Tag key={tag.id || tag.name} color="blue">{tag.name}</Tag>)}
</Space>
</div>
) : null}
<div className="info-grid">
<div className="info-item"><strong>创建人</strong>{meeting.creator_username || '未知'}</div>
<div className="info-item"><strong>会议时间</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
<div className="info-item"><strong>参会人员</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
<div className="info-item"><strong>计算人数</strong>{meeting.attendees_count || meeting.attendees?.length || 0}</div>
</div>
</div>
<div className="action-buttons">
@ -245,12 +240,26 @@ const MeetingPreview = () => {
label: (
<Space size={8}>
<FileTextOutlined />
<span>会议总结</span>
<span>总结</span>
</Space>
),
children: (
<div className="summary-content">
{meeting.summary ? <MarkdownRenderer content={meeting.summary} /> : <Empty description="暂无会议总结内容" />}
{meeting.summary ? <MarkdownRenderer content={meeting.summary} /> : <Empty description="暂无总结内容" />}
</div>
),
},
{
key: 'mindmap',
label: (
<Space size={8}>
<PartitionOutlined />
<span>思维导图</span>
</Space>
),
children: (
<div className="mindmap-wrapper">
<MindMap content={meeting.summary} title={meeting.title} />
</div>
),
},
@ -300,21 +309,7 @@ const MeetingPreview = () => {
)}
</div>
),
},
{
key: 'mindmap',
label: (
<Space size={8}>
<PartitionOutlined />
<span>思维导图</span>
</Space>
),
children: (
<div className="mindmap-wrapper">
<MindMap content={meeting.summary} title={meeting.title} />
</div>
),
},
}
]}
/>
</div>

View File

@ -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 }) => {
<Space>
{isSystem ? <Tag color="blue">系统</Tag> : <Tag>个人</Tag>}
{isSystem && item.is_default ? <Tag color="gold" icon={<StarFilled />}>默认</Tag> : null}
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => setViewingPrompt(item)}
/>
<ActionButton tone="view" variant="iconSm" tooltip="查看模板" icon={<EyeOutlined />} onClick={() => setViewingPrompt(item)} />
</Space>
</Space>
{item.desc && (

View File

@ -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 }) => {
</div>
<Space size={8} align="start">
<StatusTag active={!isDisabled} />
<Tooltip title="编辑">
<Button
size="small"
className="btn-icon-soft-blue"
icon={<EditOutlined />}
onClick={() => openEditDrawer(prompt)}
/>
</Tooltip>
<Tooltip title="删除">
<Button
size="small"
className="btn-icon-soft-red"
icon={<DeleteOutlined />}
onClick={() => removePrompt(prompt)}
/>
</Tooltip>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openEditDrawer(prompt)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => removePrompt(prompt)} />
</Space>
</div>

View File

@ -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="取消"
>
<Button danger size="small" icon={<DeleteOutlined />}>删除</Button>
<Button size="small" icon={<DeleteOutlined />} className="btn-soft-red">删除</Button>
</Popconfirm>
)}
<Button size="small" icon={<CloseOutlined />} onClick={handleCancel}>取消</Button>

View File

@ -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) => (
<Space size="middle">
<Tooltip title="打开入口">
<Button
type="text"
className="btn-text-view"
icon={<ExportOutlined />}
href={getAppEntryUrl(record)}
target="_blank"
disabled={!getAppEntryUrl(record)}
/>
</Tooltip>
<Tooltip title="编辑">
<Button type="text" className="btn-text-edit" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
</Tooltip>
<Tooltip title="删除">
<Button type="text" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Tooltip>
<Space size={6}>
<ActionButton tone="view" variant="iconSm" tooltip="打开入口" icon={<ExportOutlined />} href={getAppEntryUrl(record)} target="_blank" disabled={!getAppEntryUrl(record)} />
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Space>
),
},

View File

@ -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) => (
<Space size="middle">
<Tooltip title="编辑">
<Button type="text" className="btn-text-edit" icon={<EditOutlined />} onClick={() => openEditItem(record)} />
</Tooltip>
<Tooltip title="删除">
<Button type="text" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={() => deleteItem(record)} />
</Tooltip>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openEditItem(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => deleteItem(record)} />
</Space>
),
},
@ -300,17 +297,13 @@ const HotWordManagement = () => {
// Sync status rendering
const renderSyncStatus = (group) => {
if (group.vocabulary_id) {
return (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginTop: 4 }}>
已同步
</Tag>
);
}
return (
<Tag icon={<ExclamationCircleOutlined />} color="warning" style={{ marginTop: 4 }}>
未同步
</Tag>
<StatusTag
active={Boolean(group.vocabulary_id)}
activeText="已同步"
inactiveText="未同步"
compact
/>
);
};
@ -322,7 +315,7 @@ const HotWordManagement = () => {
<div
key={group.id}
onClick={() => 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' : ''}`}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -337,22 +330,10 @@ const HotWordManagement = () => {
</Text>
</div>
</div>
<Space size={2} onClick={(e) => e.stopPropagation()}>
<Tooltip title="同步到阿里云">
<Button
type="text" size="small"
className="btn-text-view"
icon={<SyncOutlined spin={isSyncing} />}
loading={isSyncing}
onClick={(e) => handleSync(group, e)}
/>
</Tooltip>
<Tooltip title="编辑">
<Button type="text" size="small" className="btn-text-edit" icon={<EditOutlined />} onClick={(e) => openEditGroup(group, e)} />
</Tooltip>
<Tooltip title="删除">
<Button type="text" size="small" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={(e) => deleteGroup(group, e)} />
</Tooltip>
<Space size={6} onClick={(e) => e.stopPropagation()}>
<ActionButton tone="view" variant="iconSm" tooltip="同步到阿里云" icon={<SyncOutlined spin={isSyncing} />} loading={isSyncing} onClick={(e) => handleSync(group, e)} />
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={(e) => openEditGroup(group, e)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={(e) => deleteGroup(group, e)} />
</Space>
</div>
</div>
@ -379,7 +360,7 @@ const HotWordManagement = () => {
<div style={{ display: 'flex', gap: 16, minHeight: 480 }}>
{/* ── Left: Group list ── */}
<div style={{ width: 280, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Button type="dashed" icon={<PlusOutlined />} block onClick={openAddGroup}>
<Button className="btn-pill-secondary" icon={<PlusOutlined />} block onClick={openAddGroup}>
新建热词组
</Button>
<Spin spinning={groupsLoading}>
@ -398,7 +379,13 @@ const HotWordManagement = () => {
{selectedGroup ? (
<>
{/* 选中组的阿里云同步详情 */}
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 16px' } }}>
<Card
size="small"
variant="borderless"
className="console-surface"
style={{ marginBottom: 12 }}
styles={{ body: { padding: '10px 16px' } }}
>
<Descriptions size="small" column={3}>
<Descriptions.Item label="组名">{selectedGroup.name}</Descriptions.Item>
<Descriptions.Item label="阿里云词表ID">
@ -407,7 +394,7 @@ const HotWordManagement = () => {
{selectedGroup.vocabulary_id}
</Text>
) : (
<Text type="warning">未同步</Text>
<StatusTag active={false} inactiveText="未同步" compact />
)}
</Descriptions.Item>
<Descriptions.Item label="最后同步">
@ -417,26 +404,33 @@ const HotWordManagement = () => {
</Card>
{/* Toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input
allowClear value={keyword} prefix={<SearchOutlined />}
placeholder="搜索热词" onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ width: 220 }}
/>
<Select
value={langFilter}
onChange={(v) => { setLangFilter(v); setPage(1); }}
options={languageOptions}
style={{ width: 120 }}
/>
</Space>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddItem}>
添加热词
</Button>
</Space>
</div>
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 12 }}
styles={{ body: { padding: '12px 14px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input
allowClear value={keyword} prefix={<SearchOutlined />}
placeholder="搜索热词" onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ width: 220 }}
/>
<Select
value={langFilter}
onChange={(v) => { setLangFilter(v); setPage(1); }}
options={languageOptions}
style={{ width: 120 }}
/>
</Space>
<Space>
<Button type="primary" className="btn-pill-primary" icon={<PlusOutlined />} onClick={openAddItem}>
添加热词
</Button>
</Space>
</div>
</Card>
<div className="console-table">
<Table

View File

@ -17,6 +17,7 @@ import {
Table,
Tag,
Tabs,
Tooltip,
} from 'antd';
import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
@ -29,6 +30,7 @@ import {
getProviderSelectOptions,
} from '../../config/modelProviderCatalog';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag';
const AUDIO_SCENE_OPTIONS = [
@ -382,12 +384,12 @@ const ModelManagement = () => {
{
title: '操作',
key: 'action',
width: 150,
width: 90,
render: (_, row) => (
<Space size={4}>
<Button type="link" className="btn-text-edit" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm title="确认删除该LLM模型吗" onConfirm={() => removeModel('llm', row.model_code)}>
<Button type="link" danger className="btn-text-delete" icon={<DeleteOutlined />}>删除</Button>
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
@ -425,12 +427,12 @@ const ModelManagement = () => {
{
title: '操作',
key: 'action',
width: 150,
width: 90,
render: (_, row) => (
<Space size={4}>
<Button type="link" className="btn-text-edit" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm title="确认删除该音频模型吗?" onConfirm={() => removeModel('audio', row.model_code)}>
<Button type="link" danger className="btn-text-delete" icon={<DeleteOutlined />}>删除</Button>
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),

View File

@ -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) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm
title="确认删除参数?"
description={`参数键:${row.param_key}`}
@ -110,7 +111,7 @@ const ParameterManagement = () => {
}
}}
>
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),

View File

@ -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 = () => {
<TeamOutlined style={{ color: '#1d4ed8' }} />
<Text strong>{role.role_name}</Text>
</Space>
<Button
size="small"
type="text"
className="btn-text-edit"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
openEditRoleDrawer(role);
}}
/>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑角色" icon={<EditOutlined />} onClick={(e) => {
e.stopPropagation();
openEditRoleDrawer(role);
}} />
</div>
<div style={{ marginTop: 6 }}>
<Text type="secondary">权限数 {role.menu_count || 0}</Text>
@ -928,9 +885,11 @@ const PermissionManagement = () => {
}
extra={selectedManageMenu ? (
<Space>
<Button icon={<PlusOutlined />} onClick={() => openCreateMenuPanel(selectedManageMenu.menu_id)}>新增子菜单</Button>
<Button icon={<EditOutlined />} className="btn-soft-blue" onClick={() => openEditMenuPanel(selectedManageMenu)}>编辑</Button>
<Button danger icon={<DeleteOutlined />} onClick={() => confirmDeleteMenu(selectedManageMenu)}>删除</Button>
<ActionButton tone="neutral" variant="textLg" icon={<PlusOutlined />} onClick={() => openCreateMenuPanel(selectedManageMenu.menu_id)}>
新增子菜单
</ActionButton>
<ActionButton tone="edit" variant="textLg" icon={<EditOutlined />} onClick={() => openEditMenuPanel(selectedManageMenu)}>编辑</ActionButton>
<ActionButton tone="delete" variant="textLg" icon={<DeleteOutlined />} onClick={() => confirmDeleteMenu(selectedManageMenu)}>删除</ActionButton>
</Space>
) : (
<Button icon={<PlusOutlined />} type="primary" onClick={() => openCreateMenuPanel(null)}>新增一级菜单</Button>
@ -1055,9 +1014,7 @@ const PermissionManagement = () => {
<Space>
{menuPanelMode === 'view' ? (
<Button type="primary" icon={<EditOutlined />} onClick={() => selectedManageMenu && openEditMenuPanel(selectedManageMenu)}>
编辑当前菜单
</Button>
<ActionButton tone="edit" variant="textLg" icon={<EditOutlined />} onClick={() => selectedManageMenu && openEditMenuPanel(selectedManageMenu)}>编辑当前菜单</ActionButton>
) : (
<>
<Button type="primary" loading={menuSubmitting} icon={<SaveOutlined />} onClick={submitMenu}>

View File

@ -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 {

View File

@ -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: <MessageOutlined /> },
KNOWLEDGE_TASK: { label: '知识库整合', icon: <DatabaseOutlined /> }
MEETING_TASK: { label: '总结模版', icon: <MessageOutlined /> },
KNOWLEDGE_TASK: { label: '知识库模版', icon: <DatabaseOutlined /> }
};
const PromptManagement = () => {
@ -174,13 +175,13 @@ const PromptManagement = () => {
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Title level={3} style={{ margin: 0 }}>{selectedPrompt.name}</Title>
<Button type="text" icon={<EditOutlined />} onClick={() => {}} />
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => {}} />
</div>
<Paragraph type="secondary">{selectedPrompt.desc || '暂无描述'}</Paragraph>
</div>
<Space>
<Button icon={<DeleteOutlined />} className="btn-soft-red" onClick={() => handleDelete(selectedPrompt)}>删除</Button>
<Button icon={<SaveOutlined />} type="primary" loading={isSaving} onClick={handleSave}>保存更改</Button>
<Button icon={<DeleteOutlined />} danger onClick={() => handleDelete(selectedPrompt)}>删除</Button>
</Space>
</div>
@ -229,8 +230,8 @@ const PromptManagement = () => {
</Form.Item>
<Form.Item name="task_type" label="任务类型" rules={[{ required: true }]} initialValue="MEETING_TASK">
<Select>
<Select.Option value="MEETING_TASK">会议总结</Select.Option>
<Select.Option value="KNOWLEDGE_TASK">知识库整合</Select.Option>
<Select.Option value="MEETING_TASK">总结模版</Select.Option>
<Select.Option value="KNOWLEDGE_TASK">知识库模版</Select.Option>
</Select>
</Form.Item>
<Form.Item name="desc" label="模版描述">

View File

@ -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;
}
}
}

View File

@ -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) => (
<Space size="middle">
<Tooltip title="编辑">
<Button type="text" className="btn-text-edit" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
</Tooltip>
<Tooltip title="删除">
<Button type="text" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Tooltip>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Space>
),
},

View File

@ -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) => (
<Space size="middle">
<Tooltip title="修改">
<Button type="text" className="btn-text-edit" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
</Tooltip>
<Tooltip title="重置密码">
<Button type="text" className="btn-text-accent" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)} />
</Tooltip>
<Tooltip title="删除">
<Button type="text" danger className="btn-text-delete" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Tooltip>
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="修改" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
<ActionButton tone="accent" variant="iconSm" tooltip="重置密码" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Space>
),
},

View File

@ -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;
}

View File

@ -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 智听云平台',

View File

@ -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"