Compare commits
3 Commits
abc5342258
...
af735bd93d
| Author | SHA1 | Date |
|---|---|---|
|
|
af735bd93d | |
|
|
3c2ac639b4 | |
|
|
2591996a48 |
|
|
@ -963,8 +963,7 @@ def start_meeting_transcription(
|
||||||
"task_id": existing_status['task_id'], "status": existing_status['status']
|
"task_id": existing_status['task_id'], "status": existing_status['status']
|
||||||
})
|
})
|
||||||
task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path'])
|
task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path'])
|
||||||
background_tasks.add_task(
|
async_meeting_service.enqueue_transcription_monitor(
|
||||||
async_meeting_service.monitor_and_auto_summarize,
|
|
||||||
meeting_id,
|
meeting_id,
|
||||||
task_id,
|
task_id,
|
||||||
meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None,
|
meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None,
|
||||||
|
|
@ -1103,9 +1102,24 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ
|
||||||
"task_id": transcription_status.get('task_id'),
|
"task_id": transcription_status.get('task_id'),
|
||||||
"status": transcription_status.get('status')
|
"status": transcription_status.get('status')
|
||||||
})
|
})
|
||||||
|
llm_status = async_meeting_service.get_meeting_llm_status(meeting_id)
|
||||||
|
if llm_status and llm_status.get('status') in ['pending', 'processing']:
|
||||||
|
return create_api_response(code="409", message="总结任务已存在", data={
|
||||||
|
"task_id": llm_status.get('task_id'),
|
||||||
|
"status": llm_status.get('status')
|
||||||
|
})
|
||||||
# 传递 prompt_id 和 model_code 参数给服务层
|
# 传递 prompt_id 和 model_code 参数给服务层
|
||||||
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code)
|
task_id, created = async_meeting_service.enqueue_summary_generation(
|
||||||
background_tasks.add_task(async_meeting_service._process_task, task_id)
|
meeting_id,
|
||||||
|
request.user_prompt,
|
||||||
|
request.prompt_id,
|
||||||
|
request.model_code,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
return create_api_response(code="409", message="总结任务已存在", data={
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "pending"
|
||||||
|
})
|
||||||
return create_api_response(code="200", message="Summary generation task has been accepted.", data={
|
return create_api_response(code="200", message="Summary generation task has been accepted.", data={
|
||||||
"task_id": task_id, "status": "pending", "meeting_id": meeting_id
|
"task_id": task_id, "status": "pending", "meeting_id": meeting_id
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -98,4 +98,10 @@ TRANSCRIPTION_POLL_CONFIG = {
|
||||||
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟
|
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 后台任务配置
|
||||||
|
BACKGROUND_TASK_CONFIG = {
|
||||||
|
'summary_workers': int(os.getenv('SUMMARY_TASK_MAX_WORKERS', '2')),
|
||||||
|
'monitor_workers': int(os.getenv('MONITOR_TASK_MAX_WORKERS', '8')),
|
||||||
|
'transcription_status_cache_ttl': int(os.getenv('TRANSCRIPTION_STATUS_CACHE_TTL', '3')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
异步会议服务 - 处理会议总结生成的异步任务
|
异步会议服务 - 处理会议总结生成的异步任务
|
||||||
采用FastAPI BackgroundTasks模式
|
采用受控线程池执行,避免阻塞 Web 请求进程
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
|
@ -11,11 +11,22 @@ from typing import Optional, Dict, Any, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, AUDIO_DIR
|
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||||
|
from app.services.background_task_runner import KeyedBackgroundTaskRunner
|
||||||
from app.services.llm_service import LLMService
|
from app.services.llm_service import LLMService
|
||||||
|
|
||||||
|
|
||||||
|
summary_task_runner = KeyedBackgroundTaskRunner(
|
||||||
|
max_workers=BACKGROUND_TASK_CONFIG['summary_workers'],
|
||||||
|
thread_name_prefix="imeeting-summary",
|
||||||
|
)
|
||||||
|
monitor_task_runner = KeyedBackgroundTaskRunner(
|
||||||
|
max_workers=BACKGROUND_TASK_CONFIG['monitor_workers'],
|
||||||
|
thread_name_prefix="imeeting-monitor",
|
||||||
|
)
|
||||||
|
|
||||||
class AsyncMeetingService:
|
class AsyncMeetingService:
|
||||||
"""异步会议服务类 - 处理会议相关的异步任务"""
|
"""异步会议服务类 - 处理会议相关的异步任务"""
|
||||||
|
|
||||||
|
|
@ -26,9 +37,42 @@ class AsyncMeetingService:
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||||
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
||||||
|
|
||||||
|
def enqueue_summary_generation(
|
||||||
|
self,
|
||||||
|
meeting_id: int,
|
||||||
|
user_prompt: str = "",
|
||||||
|
prompt_id: Optional[int] = None,
|
||||||
|
model_code: Optional[str] = None
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""创建并提交总结任务;若已有运行中的同会议总结任务,则直接返回现有任务。"""
|
||||||
|
existing_task = self._get_existing_summary_task(meeting_id)
|
||||||
|
if existing_task:
|
||||||
|
return existing_task, False
|
||||||
|
|
||||||
|
task_id = self.start_summary_generation(meeting_id, user_prompt, prompt_id, model_code)
|
||||||
|
summary_task_runner.submit(f"meeting-summary:{task_id}", self._process_task, task_id)
|
||||||
|
return task_id, True
|
||||||
|
|
||||||
|
def enqueue_transcription_monitor(
|
||||||
|
self,
|
||||||
|
meeting_id: int,
|
||||||
|
transcription_task_id: str,
|
||||||
|
prompt_id: Optional[int] = None,
|
||||||
|
model_code: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""提交转录监控任务,避免同一转录任务重复轮询。"""
|
||||||
|
return monitor_task_runner.submit(
|
||||||
|
f"transcription-monitor:{transcription_task_id}",
|
||||||
|
self.monitor_and_auto_summarize,
|
||||||
|
meeting_id,
|
||||||
|
transcription_task_id,
|
||||||
|
prompt_id,
|
||||||
|
model_code,
|
||||||
|
)
|
||||||
|
|
||||||
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str:
|
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。
|
创建异步总结任务,任务的执行将由后台线程池触发。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
|
|
@ -70,9 +114,11 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
def _process_task(self, task_id: str):
|
def _process_task(self, task_id: str):
|
||||||
"""
|
"""
|
||||||
处理单个异步任务的函数,设计为由BackgroundTasks调用。
|
处理单个异步任务的函数,在线程池中执行。
|
||||||
"""
|
"""
|
||||||
print(f"Background task started for meeting summary task: {task_id}")
|
print(f"Background task started for meeting summary task: {task_id}")
|
||||||
|
lock_token = None
|
||||||
|
lock_key = f"lock:meeting-summary-task:{task_id}"
|
||||||
try:
|
try:
|
||||||
# 从Redis获取任务数据
|
# 从Redis获取任务数据
|
||||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
||||||
|
|
@ -85,6 +131,10 @@ class AsyncMeetingService:
|
||||||
prompt_id_str = task_data.get('prompt_id', '')
|
prompt_id_str = task_data.get('prompt_id', '')
|
||||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
||||||
model_code = task_data.get('model_code', '') or None
|
model_code = task_data.get('model_code', '') or None
|
||||||
|
lock_token = self._acquire_lock(lock_key, ttl_seconds=7200)
|
||||||
|
if not lock_token:
|
||||||
|
print(f"Task {task_id} is already being processed, skipping duplicate execution")
|
||||||
|
return
|
||||||
|
|
||||||
# 1. 更新状态为processing
|
# 1. 更新状态为processing
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
||||||
|
|
@ -127,6 +177,8 @@ class AsyncMeetingService:
|
||||||
# 更新失败状态
|
# 更新失败状态
|
||||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
||||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
||||||
|
finally:
|
||||||
|
self._release_lock(lock_key, lock_token)
|
||||||
|
|
||||||
def monitor_and_auto_summarize(
|
def monitor_and_auto_summarize(
|
||||||
self,
|
self,
|
||||||
|
|
@ -156,6 +208,11 @@ class AsyncMeetingService:
|
||||||
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
||||||
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
||||||
max_polls = max_wait_time // poll_interval
|
max_polls = max_wait_time // poll_interval
|
||||||
|
lock_key = f"lock:transcription-monitor:{transcription_task_id}"
|
||||||
|
lock_token = self._acquire_lock(lock_key, ttl_seconds=max_wait_time + poll_interval)
|
||||||
|
if not lock_token:
|
||||||
|
print(f"[Monitor] Monitor task already running for transcription task {transcription_task_id}, skipping duplicate worker")
|
||||||
|
return
|
||||||
|
|
||||||
# 延迟导入以避免循环导入
|
# 延迟导入以避免循环导入
|
||||||
transcription_service = AsyncTranscriptionService()
|
transcription_service = AsyncTranscriptionService()
|
||||||
|
|
@ -186,16 +243,16 @@ class AsyncMeetingService:
|
||||||
else:
|
else:
|
||||||
# 启动总结任务
|
# 启动总结任务
|
||||||
try:
|
try:
|
||||||
summary_task_id = self.start_summary_generation(
|
summary_task_id, created = self.enqueue_summary_generation(
|
||||||
meeting_id,
|
meeting_id,
|
||||||
user_prompt="",
|
user_prompt="",
|
||||||
prompt_id=prompt_id,
|
prompt_id=prompt_id,
|
||||||
model_code=model_code
|
model_code=model_code
|
||||||
)
|
)
|
||||||
|
if created:
|
||||||
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
|
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
|
||||||
|
else:
|
||||||
# 在后台执行总结任务
|
print(f"[Monitor] Reused existing summary task {summary_task_id} for meeting {meeting_id}")
|
||||||
self._process_task(summary_task_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to start summary generation: {e}"
|
error_msg = f"Failed to start summary generation: {e}"
|
||||||
|
|
@ -232,6 +289,8 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}")
|
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}")
|
||||||
|
finally:
|
||||||
|
self._release_lock(lock_key, lock_token)
|
||||||
|
|
||||||
# --- 会议相关方法 ---
|
# --- 会议相关方法 ---
|
||||||
|
|
||||||
|
|
@ -682,5 +741,30 @@ class AsyncMeetingService:
|
||||||
print(f"Error checking existing summary task: {e}")
|
print(f"Error checking existing summary task: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _acquire_lock(self, lock_key: str, ttl_seconds: int) -> Optional[str]:
|
||||||
|
"""使用 Redis 分布式锁防止多 worker 重复执行同一后台任务。"""
|
||||||
|
try:
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
acquired = self.redis_client.set(lock_key, token, nx=True, ex=max(30, ttl_seconds))
|
||||||
|
if acquired:
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error acquiring lock {lock_key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _release_lock(self, lock_key: str, token: Optional[str]) -> None:
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
release_script = """
|
||||||
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('del', KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
"""
|
||||||
|
self.redis_client.eval(release_script, 1, lock_key, token)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error releasing lock {lock_key}: {e}")
|
||||||
|
|
||||||
# 创建全局实例
|
# 创建全局实例
|
||||||
async_meeting_service = AsyncMeetingService()
|
async_meeting_service = AsyncMeetingService()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from http import HTTPStatus
|
||||||
import dashscope
|
import dashscope
|
||||||
from dashscope.audio.asr import Transcription
|
from dashscope.audio.asr import Transcription
|
||||||
|
|
||||||
from app.core.config import QWEN_API_KEY, REDIS_CONFIG, APP_CONFIG
|
from app.core.config import QWEN_API_KEY, REDIS_CONFIG, APP_CONFIG, BACKGROUND_TASK_CONFIG
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.services.system_config_service import SystemConfigService
|
from app.services.system_config_service import SystemConfigService
|
||||||
|
|
||||||
|
|
@ -211,10 +211,26 @@ class AsyncTranscriptionService:
|
||||||
current_status = 'failed'
|
current_status = 'failed'
|
||||||
progress = 0
|
progress = 0
|
||||||
error_message = "An unknown error occurred."
|
error_message = "An unknown error occurred."
|
||||||
|
updated_at = datetime.now().isoformat()
|
||||||
|
status_cache_key = f"task_status_cache:{business_task_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 获取任务数据(优先Redis,回源DB)
|
# 1. 获取任务数据(优先Redis,回源DB)
|
||||||
task_data = self._get_task_data(business_task_id)
|
task_data = self._get_task_data(business_task_id)
|
||||||
|
stored_status = str(task_data.get('status') or '').lower()
|
||||||
|
if stored_status in {'completed', 'failed'}:
|
||||||
|
current_status = stored_status
|
||||||
|
progress = int(task_data.get('progress') or 0)
|
||||||
|
error_message = task_data.get('error_message') or None
|
||||||
|
updated_at = task_data.get('updated_at') or updated_at
|
||||||
|
else:
|
||||||
|
cached_status = self.redis_client.hgetall(status_cache_key)
|
||||||
|
if cached_status and cached_status.get('status') in {'pending', 'processing'}:
|
||||||
|
current_status = cached_status.get('status', 'pending')
|
||||||
|
progress = int(cached_status.get('progress') or 0)
|
||||||
|
error_message = cached_status.get('error_message') or None
|
||||||
|
updated_at = cached_status.get('updated_at') or updated_at
|
||||||
|
else:
|
||||||
paraformer_task_id = task_data['paraformer_task_id']
|
paraformer_task_id = task_data['paraformer_task_id']
|
||||||
|
|
||||||
# 2. 查询外部API获取状态
|
# 2. 查询外部API获取状态
|
||||||
|
|
@ -236,8 +252,6 @@ class AsyncTranscriptionService:
|
||||||
current_status = 'failed'
|
current_status = 'failed'
|
||||||
progress = 0
|
progress = 0
|
||||||
error_message = f"Error fetching status from provider: {e}"
|
error_message = f"Error fetching status from provider: {e}"
|
||||||
# 直接进入finally块更新状态后返回
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 如果任务完成,处理结果
|
# 3. 如果任务完成,处理结果
|
||||||
if current_status == 'completed' and paraformer_response.output.get('results'):
|
if current_status == 'completed' and paraformer_response.output.get('results'):
|
||||||
|
|
@ -281,33 +295,33 @@ class AsyncTranscriptionService:
|
||||||
if error_message:
|
if error_message:
|
||||||
update_data['error_message'] = error_message
|
update_data['error_message'] = error_message
|
||||||
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
|
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
|
||||||
|
self.redis_client.hset(status_cache_key, mapping=update_data)
|
||||||
|
self.redis_client.expire(
|
||||||
|
status_cache_key,
|
||||||
|
max(1, int(BACKGROUND_TASK_CONFIG.get('transcription_status_cache_ttl', 3)))
|
||||||
|
)
|
||||||
|
|
||||||
# 更新数据库
|
# 更新数据库
|
||||||
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
|
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
|
||||||
|
|
||||||
# 5. 构造并返回最终结果
|
# 5. 构造并返回最终结果
|
||||||
result = {
|
return self._build_task_status_result(
|
||||||
'task_id': business_task_id,
|
business_task_id,
|
||||||
'status': current_status,
|
task_data,
|
||||||
'progress': progress,
|
current_status,
|
||||||
'error_message': error_message,
|
progress,
|
||||||
'updated_at': updated_at,
|
error_message,
|
||||||
'meeting_id': None,
|
updated_at,
|
||||||
'created_at': None,
|
)
|
||||||
}
|
|
||||||
if task_data:
|
|
||||||
result['meeting_id'] = int(task_data['meeting_id'])
|
|
||||||
result['created_at'] = task_data.get('created_at')
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
||||||
"""从Redis或数据库获取任务数据"""
|
"""从Redis或数据库获取任务数据"""
|
||||||
# 尝试从Redis获取
|
# 尝试从Redis获取
|
||||||
task_data_bytes = self.redis_client.hgetall(f"task:{business_task_id}")
|
task_data_raw = self.redis_client.hgetall(f"task:{business_task_id}")
|
||||||
if task_data_bytes and task_data_bytes.get(b'paraformer_task_id'):
|
if task_data_raw:
|
||||||
# Redis返回的是bytes,需要解码
|
task_data = self._normalize_redis_mapping(task_data_raw)
|
||||||
return {k.decode('utf-8'): v.decode('utf-8') for k, v in task_data_bytes.items()}
|
if task_data.get('paraformer_task_id'):
|
||||||
|
return task_data
|
||||||
|
|
||||||
# 如果Redis没有,从数据库回源
|
# 如果Redis没有,从数据库回源
|
||||||
task_data_from_db = self._get_task_from_db(business_task_id)
|
task_data_from_db = self._get_task_from_db(business_task_id)
|
||||||
|
|
@ -465,7 +479,8 @@ class AsyncTranscriptionService:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.created_at
|
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.progress,
|
||||||
|
tt.error_message, tt.created_at, tt.completed_at
|
||||||
FROM transcript_tasks tt
|
FROM transcript_tasks tt
|
||||||
WHERE tt.task_id = %s
|
WHERE tt.task_id = %s
|
||||||
"""
|
"""
|
||||||
|
|
@ -480,7 +495,14 @@ class AsyncTranscriptionService:
|
||||||
'paraformer_task_id': result['paraformer_task_id'],
|
'paraformer_task_id': result['paraformer_task_id'],
|
||||||
'meeting_id': str(result['meeting_id']),
|
'meeting_id': str(result['meeting_id']),
|
||||||
'status': result['status'],
|
'status': result['status'],
|
||||||
'created_at': result['created_at'].isoformat() if result['created_at'] else None
|
'progress': str(result.get('progress') or 0),
|
||||||
|
'error_message': result.get('error_message') or '',
|
||||||
|
'created_at': result['created_at'].isoformat() if result['created_at'] else None,
|
||||||
|
'updated_at': (
|
||||||
|
result['completed_at'].isoformat()
|
||||||
|
if result.get('completed_at')
|
||||||
|
else result['created_at'].isoformat() if result.get('created_at') else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -488,6 +510,41 @@ class AsyncTranscriptionService:
|
||||||
print(f"Error getting task from database: {e}")
|
print(f"Error getting task from database: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _normalize_redis_mapping(self, mapping: Dict[Any, Any]) -> Dict[str, Any]:
|
||||||
|
normalized: Dict[str, Any] = {}
|
||||||
|
for key, value in mapping.items():
|
||||||
|
normalized_key = key.decode('utf-8') if isinstance(key, bytes) else str(key)
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
normalized_value = value.decode('utf-8')
|
||||||
|
else:
|
||||||
|
normalized_value = value
|
||||||
|
normalized[normalized_key] = normalized_value
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _build_task_status_result(
|
||||||
|
self,
|
||||||
|
business_task_id: str,
|
||||||
|
task_data: Optional[Dict[str, Any]],
|
||||||
|
status: str,
|
||||||
|
progress: int,
|
||||||
|
error_message: Optional[str],
|
||||||
|
updated_at: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
'task_id': business_task_id,
|
||||||
|
'status': status,
|
||||||
|
'progress': progress,
|
||||||
|
'error_message': error_message,
|
||||||
|
'updated_at': updated_at,
|
||||||
|
'meeting_id': None,
|
||||||
|
'created_at': None,
|
||||||
|
}
|
||||||
|
if task_data:
|
||||||
|
meeting_id = task_data.get('meeting_id')
|
||||||
|
result['meeting_id'] = int(meeting_id) if meeting_id is not None else None
|
||||||
|
result['created_at'] = task_data.get('created_at')
|
||||||
|
return result
|
||||||
|
|
||||||
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
|
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
|
||||||
"""
|
"""
|
||||||
处理转录结果.
|
处理转录结果.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def handle_audio_upload(
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
current_user: 当前用户信息
|
current_user: 当前用户信息
|
||||||
auto_summarize: 是否自动生成总结(默认True)
|
auto_summarize: 是否自动生成总结(默认True)
|
||||||
background_tasks: FastAPI 后台任务对象
|
background_tasks: 为兼容现有调用保留,实际后台执行由服务层线程池完成
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
duration: 音频时长(秒)
|
duration: 音频时长(秒)
|
||||||
|
|
@ -141,16 +141,15 @@ def handle_audio_upload(
|
||||||
transcription_task_id = transcription_service.start_transcription(meeting_id, file_path)
|
transcription_task_id = transcription_service.start_transcription(meeting_id, file_path)
|
||||||
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
|
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
|
||||||
|
|
||||||
# 5. 如果启用自动总结且提供了 background_tasks,添加监控任务
|
# 5. 如果启用自动总结,则提交后台监控任务
|
||||||
if auto_summarize and transcription_task_id and background_tasks:
|
if auto_summarize and transcription_task_id:
|
||||||
background_tasks.add_task(
|
async_meeting_service.enqueue_transcription_monitor(
|
||||||
async_meeting_service.monitor_and_auto_summarize,
|
|
||||||
meeting_id,
|
meeting_id,
|
||||||
transcription_task_id,
|
transcription_task_id,
|
||||||
prompt_id,
|
prompt_id,
|
||||||
model_code
|
model_code
|
||||||
)
|
)
|
||||||
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
|
print(f"[audio_service] Auto-summarize enabled, monitor task scheduled for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to start transcription: {e}")
|
print(f"Failed to start transcription: {e}")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from itertools import count
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Callable, Dict
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
class KeyedBackgroundTaskRunner:
|
||||||
|
"""按 key 去重的后台任务执行器,避免同类长任务重复堆积。"""
|
||||||
|
|
||||||
|
def __init__(self, max_workers: int, thread_name_prefix: str):
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=thread_name_prefix)
|
||||||
|
self._lock = Lock()
|
||||||
|
self._seq = count(1)
|
||||||
|
self._tasks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def submit(self, key: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
current = self._tasks.get(key)
|
||||||
|
if current and not current["future"].done():
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = next(self._seq)
|
||||||
|
future = self._executor.submit(self._run_task, key, token, func, *args, **kwargs)
|
||||||
|
self._tasks[key] = {"token": token, "future": future}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _run_task(self, key: str, token: int, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
||||||
|
try:
|
||||||
|
func(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
print(f"[BackgroundTaskRunner] Task failed, key={key}")
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
current = self._tasks.get(key)
|
||||||
|
if current and current["token"] == token:
|
||||||
|
self._tasks.pop(key, None)
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
.audio-player-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e3ebf6;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #f7faff 100%);
|
||||||
|
box-shadow: 0 8px 18px rgba(40, 72, 120, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar.is-empty {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-play {
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f7392;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-play.ant-btn:hover,
|
||||||
|
.audio-player-bar-play.ant-btn:focus {
|
||||||
|
background: rgba(233, 241, 251, 0.7) !important;
|
||||||
|
color: #355171 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-play.ant-btn:disabled {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #a8b7ca !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 102px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5f7392;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 5px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf2fa;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress::-webkit-slider-runnable-track {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#bfd1e8 0%,
|
||||||
|
#bfd1e8 var(--progress, 0%),
|
||||||
|
#edf2fa var(--progress, 0%),
|
||||||
|
#edf2fa 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: -2.5px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #b9cde7;
|
||||||
|
box-shadow: 0 2px 5px rgba(79, 111, 157, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress::-moz-range-track {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf2fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress::-moz-range-progress {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #bfd1e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-progress::-moz-range-thumb {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #b9cde7;
|
||||||
|
box-shadow: 0 2px 5px rgba(79, 111, 157, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-volume {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5f7392;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: #e4ebf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-control.ant-btn {
|
||||||
|
height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f7392;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-control.ant-btn:hover,
|
||||||
|
.audio-player-bar-control.ant-btn:focus {
|
||||||
|
background: rgba(240, 245, 252, 0.75) !important;
|
||||||
|
color: #355171 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-control.ant-btn:disabled {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
color: #a8b7ca !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-rate.ant-btn {
|
||||||
|
min-width: 46px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-more.ant-btn {
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-empty-text {
|
||||||
|
color: #5f7392;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.audio-player-bar {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-time {
|
||||||
|
min-width: 78px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-bar-volume,
|
||||||
|
.audio-player-bar-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Dropdown } from 'antd';
|
||||||
|
import {
|
||||||
|
CaretRightFilled,
|
||||||
|
DownOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
PauseOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import tools from '../utils/tools';
|
||||||
|
import './AudioPlayerBar.css';
|
||||||
|
|
||||||
|
const DEFAULT_RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2];
|
||||||
|
|
||||||
|
const AudioPlayerBar = ({
|
||||||
|
audioRef,
|
||||||
|
src,
|
||||||
|
playbackRate = 1,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
moreMenuItems = [],
|
||||||
|
emptyText = '暂无音频',
|
||||||
|
showMoreButton = true,
|
||||||
|
rateOptions = DEFAULT_RATE_OPTIONS,
|
||||||
|
}) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef?.current;
|
||||||
|
if (!audio) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncState = () => {
|
||||||
|
setCurrentTime(audio.currentTime || 0);
|
||||||
|
setDuration(audio.duration || 0);
|
||||||
|
setIsPlaying(!audio.paused && !audio.ended);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMeta = (event) => {
|
||||||
|
syncState();
|
||||||
|
onLoadedMetadata?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTime = (event) => {
|
||||||
|
syncState();
|
||||||
|
onTimeUpdate?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(audio.duration || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
syncState();
|
||||||
|
audio.addEventListener('loadedmetadata', handleMeta);
|
||||||
|
audio.addEventListener('durationchange', syncState);
|
||||||
|
audio.addEventListener('timeupdate', handleTime);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', handleMeta);
|
||||||
|
audio.removeEventListener('durationchange', syncState);
|
||||||
|
audio.removeEventListener('timeupdate', handleTime);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
};
|
||||||
|
}, [audioRef, onLoadedMetadata, onTimeUpdate, src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef?.current) {
|
||||||
|
audioRef.current.playbackRate = playbackRate;
|
||||||
|
}
|
||||||
|
}, [audioRef, playbackRate]);
|
||||||
|
|
||||||
|
const rateMenuItems = useMemo(
|
||||||
|
() => rateOptions.map((rate) => ({
|
||||||
|
key: String(rate),
|
||||||
|
label: `${rate.toFixed(rate % 1 === 0 ? 1 : 2)}x`,
|
||||||
|
onClick: () => onPlaybackRateChange?.(rate),
|
||||||
|
})),
|
||||||
|
[onPlaybackRateChange, rateOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const progress = duration > 0 ? Math.min((currentTime / duration) * 100, 100) : 0;
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
|
const audio = audioRef?.current;
|
||||||
|
if (!audio || !src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.paused || audio.ended) {
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
} catch {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (event) => {
|
||||||
|
const audio = audioRef?.current;
|
||||||
|
if (!audio || !src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTime = Number(event.target.value);
|
||||||
|
audio.currentTime = nextTime;
|
||||||
|
setCurrentTime(nextTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className="audio-player-bar is-empty">
|
||||||
|
<span className="audio-player-bar-empty-text">{emptyText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audio-player-bar">
|
||||||
|
<audio ref={audioRef} className="audio-player-bar-audio" src={src} preload="metadata" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="audio-player-bar-play"
|
||||||
|
icon={isPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
||||||
|
onClick={togglePlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="audio-player-bar-time">
|
||||||
|
{tools.formatDuration(currentTime)} / {tools.formatDuration(duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="audio-player-bar-progress"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step={0.1}
|
||||||
|
value={Math.min(currentTime, duration || 0)}
|
||||||
|
onChange={handleSeek}
|
||||||
|
style={{ '--progress': `${progress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="audio-player-bar-volume">
|
||||||
|
<SoundOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="audio-player-bar-divider" />
|
||||||
|
|
||||||
|
<Dropdown menu={{ items: rateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }} trigger={['click']}>
|
||||||
|
<Button type="text" className="audio-player-bar-control audio-player-bar-rate">
|
||||||
|
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
|
||||||
|
<DownOutlined />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{showMoreButton ? (
|
||||||
|
<>
|
||||||
|
<span className="audio-player-bar-divider" />
|
||||||
|
{moreMenuItems.length > 0 ? (
|
||||||
|
<Dropdown menu={{ items: moreMenuItems }} trigger={['click']}>
|
||||||
|
<Button type="text" className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Button type="text" disabled className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPlayerBar;
|
||||||
|
|
@ -6,9 +6,16 @@ import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined, SyncOutlined } fro
|
||||||
|
|
||||||
const transformer = new Transformer();
|
const transformer = new Transformer();
|
||||||
|
|
||||||
|
const hasRenderableSize = (element) => {
|
||||||
|
if (!element) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
|
||||||
const MindMap = ({ content, title }) => {
|
const MindMap = ({ content, title }) => {
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const markmapRef = useRef(null);
|
const markmapRef = useRef(null);
|
||||||
|
const latestRootRef = useRef(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -17,16 +24,22 @@ const MindMap = ({ content, title }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { root } = transformer.transform(content);
|
const { root } = transformer.transform(content);
|
||||||
|
latestRootRef.current = root;
|
||||||
|
|
||||||
if (markmapRef.current) {
|
if (markmapRef.current) {
|
||||||
markmapRef.current.setData(root);
|
markmapRef.current.setData(root);
|
||||||
markmapRef.current.fit();
|
|
||||||
} else {
|
} else {
|
||||||
markmapRef.current = Markmap.create(svgRef.current, {
|
markmapRef.current = Markmap.create(svgRef.current, {
|
||||||
autoFit: true,
|
autoFit: false,
|
||||||
duration: 500,
|
duration: 500,
|
||||||
}, root);
|
}, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (svgRef.current && hasRenderableSize(svgRef.current)) {
|
||||||
|
markmapRef.current?.fit();
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Markmap error:', error);
|
console.error('Markmap error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -34,6 +47,33 @@ const MindMap = ({ content, title }) => {
|
||||||
}
|
}
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const svgElement = svgRef.current;
|
||||||
|
if (!svgElement || typeof ResizeObserver === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (!hasRenderableSize(svgElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markmapRef.current && latestRootRef.current) {
|
||||||
|
markmapRef.current = Markmap.create(svgElement, {
|
||||||
|
autoFit: false,
|
||||||
|
duration: 500,
|
||||||
|
}, latestRootRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
markmapRef.current?.fit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(svgElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFit = () => markmapRef.current?.fit();
|
const handleFit = () => markmapRef.current?.fit();
|
||||||
const handleZoomIn = () => markmapRef.current?.rescale(1.2);
|
const handleZoomIn = () => markmapRef.current?.rescale(1.2);
|
||||||
const handleZoomOut = () => markmapRef.current?.rescale(0.8);
|
const handleZoomOut = () => markmapRef.current?.rescale(0.8);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
.transcript-scroll-panel {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 12px 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-scroll-panel-fill {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry {
|
||||||
|
margin-left: -4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry:hover {
|
||||||
|
background: #f8fbff;
|
||||||
|
border-color: #d9e8fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry.is-active {
|
||||||
|
background: linear-gradient(180deg, #eef6ff 0%, #e2f0ff 100%);
|
||||||
|
border-color: #bfd8fb;
|
||||||
|
box-shadow: 0 10px 22px rgba(29, 78, 216, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--speaker-color, #1677ff);
|
||||||
|
box-shadow: none;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-dot.is-active {
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-time.ant-typography {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-speaker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-avatar.ant-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-speaker-label.ant-typography {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-speaker-label.is-editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-speaker-edit {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-content {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-content.ant-typography {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-render-hint {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.transcript-scroll-panel {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry {
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-entry-content {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Timeline,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import tools from '../utils/tools';
|
||||||
|
import './TranscriptTimeline.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const TranscriptTimeline = ({
|
||||||
|
transcript = [],
|
||||||
|
loading = false,
|
||||||
|
visibleCount,
|
||||||
|
currentHighlightIndex = -1,
|
||||||
|
onJumpToTime,
|
||||||
|
onScroll,
|
||||||
|
transcriptRefs,
|
||||||
|
getSpeakerColor,
|
||||||
|
emptyDescription = '暂无对话数据',
|
||||||
|
loadingTip = '正在加载转录内容...',
|
||||||
|
showRenderHint = false,
|
||||||
|
fillHeight = false,
|
||||||
|
maxHeight = null,
|
||||||
|
editable = false,
|
||||||
|
isMeetingOwner = false,
|
||||||
|
editing = {},
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
inlineSpeakerEdit = null,
|
||||||
|
inlineSpeakerEditSegmentId = null,
|
||||||
|
inlineSpeakerValue = '',
|
||||||
|
setInlineSpeakerValue,
|
||||||
|
startInlineSpeakerEdit,
|
||||||
|
saveInlineSpeakerEdit,
|
||||||
|
cancelInlineSpeakerEdit,
|
||||||
|
inlineSegmentEditId = null,
|
||||||
|
inlineSegmentValue = '',
|
||||||
|
setInlineSegmentValue,
|
||||||
|
startInlineSegmentEdit,
|
||||||
|
saveInlineSegmentEdit,
|
||||||
|
cancelInlineSegmentEdit,
|
||||||
|
savingInlineEdit = false,
|
||||||
|
} = editing;
|
||||||
|
|
||||||
|
const renderCount = Math.min(visibleCount ?? transcript.length, transcript.length);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
<div className="transcript-loading">
|
||||||
|
<Spin size="large" tip={loadingTip} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcript.length) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} style={{ marginTop: 80 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
<Timeline
|
||||||
|
mode="left"
|
||||||
|
className="transcript-timeline"
|
||||||
|
items={transcript.slice(0, renderCount).map((item, index) => {
|
||||||
|
const isActive = currentHighlightIndex === index;
|
||||||
|
const speakerColor = getSpeakerColor(item.speaker_id);
|
||||||
|
const speakerEditKey = `speaker-${item.speaker_id}-${item.segment_id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: (
|
||||||
|
<Text type="secondary" className="transcript-entry-time">
|
||||||
|
{tools.formatDuration(item.start_time_ms / 1000)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
dot: (
|
||||||
|
<span
|
||||||
|
className={`transcript-entry-dot${isActive ? ' is-active' : ''}`}
|
||||||
|
style={{ '--speaker-color': speakerColor }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (transcriptRefs?.current) {
|
||||||
|
transcriptRefs.current[index] = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`transcript-entry${isActive ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onJumpToTime?.(item.start_time_ms)}
|
||||||
|
>
|
||||||
|
<div className="transcript-entry-header">
|
||||||
|
<div className="transcript-entry-speaker">
|
||||||
|
<Avatar
|
||||||
|
size={24}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
className="transcript-entry-avatar"
|
||||||
|
style={{ backgroundColor: speakerColor }}
|
||||||
|
/>
|
||||||
|
{editable && inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === speakerEditKey ? (
|
||||||
|
<Space.Compact onClick={(event) => event.stopPropagation()}>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
value={inlineSpeakerValue}
|
||||||
|
onChange={(event) => setInlineSpeakerValue?.(event.target.value)}
|
||||||
|
onPressEnter={saveInlineSpeakerEdit}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
loading={savingInlineEdit}
|
||||||
|
onClick={saveInlineSpeakerEdit}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
disabled={savingInlineEdit}
|
||||||
|
onClick={cancelInlineSpeakerEdit}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
className={`transcript-entry-speaker-label${editable && isMeetingOwner ? ' is-editable' : ''}`}
|
||||||
|
onClick={editable && isMeetingOwner ? (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startInlineSpeakerEdit?.(item.speaker_id, item.speaker_tag, item.segment_id);
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
||||||
|
{editable && isMeetingOwner ? <EditOutlined className="transcript-entry-speaker-edit" /> : null}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editable && inlineSegmentEditId === item.segment_id ? (
|
||||||
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
|
<Input.TextArea
|
||||||
|
autoFocus
|
||||||
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||||
|
value={inlineSegmentValue}
|
||||||
|
onChange={(event) => setInlineSegmentValue?.(event.target.value)}
|
||||||
|
onPressEnter={(event) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
saveInlineSegmentEdit?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Space style={{ marginTop: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
className="transcript-entry-content"
|
||||||
|
onDoubleClick={editable && isMeetingOwner ? (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startInlineSegmentEdit?.(item);
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{item.text_content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{showRenderHint && renderCount < transcript.length ? (
|
||||||
|
<div className="transcript-render-hint">
|
||||||
|
<Text type="secondary">
|
||||||
|
已渲染 {renderCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TranscriptTimeline;
|
||||||
|
|
@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
|
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
|
||||||
Tooltip, Progress, Spin, App, Dropdown,
|
Tooltip, Progress, Spin, App, Dropdown,
|
||||||
Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
|
Divider, List, Tabs, Input, Upload, Empty, Drawer, Select, Switch
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined, UserOutlined, TeamOutlined,
|
ClockCircleOutlined, UserOutlined, TeamOutlined,
|
||||||
|
|
@ -13,13 +13,15 @@ import {
|
||||||
EyeOutlined, FileTextOutlined, PartitionOutlined,
|
EyeOutlined, FileTextOutlined, PartitionOutlined,
|
||||||
SaveOutlined, CloseOutlined,
|
SaveOutlined, CloseOutlined,
|
||||||
StarFilled, RobotOutlined, DownloadOutlined,
|
StarFilled, RobotOutlined, DownloadOutlined,
|
||||||
DownOutlined, CheckOutlined,
|
CheckOutlined,
|
||||||
MoreOutlined, AudioOutlined, CopyOutlined
|
MoreOutlined, AudioOutlined, CopyOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||||
import MarkdownEditor from '../components/MarkdownEditor';
|
import MarkdownEditor from '../components/MarkdownEditor';
|
||||||
import MindMap from '../components/MindMap';
|
import MindMap from '../components/MindMap';
|
||||||
import ActionButton from '../components/ActionButton';
|
import ActionButton from '../components/ActionButton';
|
||||||
|
import AudioPlayerBar from '../components/AudioPlayerBar';
|
||||||
|
import TranscriptTimeline from '../components/TranscriptTimeline';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import tools from '../utils/tools';
|
import tools from '../utils/tools';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
@ -823,14 +825,6 @@ const MeetingDetails = ({ user }) => {
|
||||||
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
|
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
|
||||||
];
|
];
|
||||||
|
|
||||||
const playbackRateMenuItems = [
|
|
||||||
{ key: '0.75', label: '0.75x', onClick: () => changePlaybackRate(0.75) },
|
|
||||||
{ key: '1', label: '1.0x', onClick: () => changePlaybackRate(1) },
|
|
||||||
{ key: '1.25', label: '1.25x', onClick: () => changePlaybackRate(1.25) },
|
|
||||||
{ key: '1.5', label: '1.5x', onClick: () => changePlaybackRate(1.5) },
|
|
||||||
{ key: '2', label: '2.0x', onClick: () => changePlaybackRate(2) },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ══════════════════ 渲染 ══════════════════ */
|
/* ══════════════════ 渲染 ══════════════════ */
|
||||||
|
|
||||||
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
|
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
|
||||||
|
|
@ -983,188 +977,54 @@ const MeetingDetails = ({ user }) => {
|
||||||
|
|
||||||
{/* 音频播放器 */}
|
{/* 音频播放器 */}
|
||||||
<div style={{ padding: '8px 20px 0' }}>
|
<div style={{ padding: '8px 20px 0' }}>
|
||||||
<div className="meeting-audio-toolbar">
|
<AudioPlayerBar
|
||||||
<div className="meeting-audio-toolbar-player">
|
audioRef={audioRef}
|
||||||
{audioUrl ? (
|
|
||||||
<audio
|
|
||||||
className="meeting-audio-toolbar-native"
|
|
||||||
ref={audioRef}
|
|
||||||
src={audioUrl}
|
src={audioUrl}
|
||||||
controls
|
playbackRate={playbackRate}
|
||||||
controlsList="nodownload noplaybackrate"
|
onPlaybackRateChange={changePlaybackRate}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={() => {
|
onLoadedMetadata={() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.playbackRate = playbackRate;
|
audioRef.current.playbackRate = playbackRate;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
moreMenuItems={audioMoreMenuItems}
|
||||||
style={{ width: '100%', height: 36 }}
|
emptyText="暂无音频,可通过右侧更多操作上传音频"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="meeting-audio-toolbar-empty">
|
|
||||||
<Text type="secondary">暂无音频,可通过右侧更多操作上传音频</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="meeting-audio-toolbar-actions">
|
|
||||||
<Dropdown
|
|
||||||
menu={{ items: playbackRateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }}
|
|
||||||
trigger={['click']}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="meeting-audio-toolbar-button meeting-audio-toolbar-rate btn-pill-secondary"
|
|
||||||
disabled={!audioUrl}
|
|
||||||
>
|
|
||||||
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
|
|
||||||
<DownOutlined />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
<Dropdown menu={{ items: audioMoreMenuItems }} trigger={['click']}>
|
|
||||||
<Button
|
|
||||||
className="meeting-audio-toolbar-button meeting-audio-toolbar-more btn-pill-secondary"
|
|
||||||
icon={<MoreOutlined />}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转录时间轴 */}
|
{/* 转录时间轴 */}
|
||||||
<div
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', minHeight: 0 }}>
|
||||||
style={{ flex: 1, overflowY: 'auto', padding: '12px 12px 12px 4px' }}
|
<TranscriptTimeline
|
||||||
|
transcript={transcript}
|
||||||
|
loading={transcriptLoading}
|
||||||
|
visibleCount={transcriptVisibleCount}
|
||||||
|
currentHighlightIndex={currentHighlightIndex}
|
||||||
|
onJumpToTime={jumpToTime}
|
||||||
onScroll={handleTranscriptScroll}
|
onScroll={handleTranscriptScroll}
|
||||||
>
|
transcriptRefs={transcriptRefs}
|
||||||
{transcriptLoading ? (
|
getSpeakerColor={getSpeakerColor}
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 120 }}>
|
showRenderHint
|
||||||
<Spin size="large" tip="正在加载转录内容..." />
|
fillHeight
|
||||||
</div>
|
editable
|
||||||
) : transcript.length > 0 ? (
|
isMeetingOwner={isMeetingOwner}
|
||||||
<>
|
editing={{
|
||||||
<Timeline
|
inlineSpeakerEdit,
|
||||||
mode="left"
|
inlineSpeakerEditSegmentId,
|
||||||
className="transcript-timeline"
|
inlineSpeakerValue,
|
||||||
items={transcript.slice(0, transcriptVisibleCount).map((item, index) => {
|
setInlineSpeakerValue,
|
||||||
const isActive = currentHighlightIndex === index;
|
startInlineSpeakerEdit,
|
||||||
return {
|
saveInlineSpeakerEdit,
|
||||||
label: (
|
cancelInlineSpeakerEdit,
|
||||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
inlineSegmentEditId,
|
||||||
{tools.formatDuration(item.start_time_ms / 1000)}
|
inlineSegmentValue,
|
||||||
</Text>
|
setInlineSegmentValue,
|
||||||
),
|
startInlineSegmentEdit,
|
||||||
dot: (
|
saveInlineSegmentEdit,
|
||||||
<div style={{
|
cancelInlineSegmentEdit,
|
||||||
width: 10, height: 10, borderRadius: '50%',
|
savingInlineEdit,
|
||||||
background: getSpeakerColor(item.speaker_id),
|
|
||||||
boxShadow: isActive ? `0 0 0 3px ${getSpeakerColor(item.speaker_id)}33` : 'none',
|
|
||||||
}} />
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div
|
|
||||||
ref={el => transcriptRefs.current[index] = el}
|
|
||||||
style={{
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: isActive ? '#e6f4ff' : 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
marginLeft: -4,
|
|
||||||
}}
|
|
||||||
onClick={() => jumpToTime(item.start_time_ms)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
|
|
||||||
<Avatar
|
|
||||||
size={24}
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
|
|
||||||
<Space.Compact onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Input
|
|
||||||
size="small"
|
|
||||||
autoFocus
|
|
||||||
value={inlineSpeakerValue}
|
|
||||||
onChange={(e) => setInlineSpeakerValue(e.target.value)}
|
|
||||||
onPressEnter={saveInlineSpeakerEdit}
|
|
||||||
style={{ width: 180 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
icon={<CheckOutlined />}
|
|
||||||
loading={savingInlineEdit}
|
|
||||||
onClick={saveInlineSpeakerEdit}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
disabled={savingInlineEdit}
|
|
||||||
onClick={cancelInlineSpeakerEdit}
|
|
||||||
/>
|
|
||||||
</Space.Compact>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{ color: '#1677ff', cursor: isMeetingOwner ? 'pointer' : 'default', fontSize: 13 }}
|
|
||||||
onClick={isMeetingOwner ? (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
|
|
||||||
} : undefined}
|
|
||||||
>
|
|
||||||
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
|
||||||
{isMeetingOwner ? <EditOutlined style={{ fontSize: 11, marginLeft: 3 }} /> : null}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{inlineSegmentEditId === item.segment_id ? (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Input.TextArea
|
|
||||||
autoFocus
|
|
||||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
||||||
value={inlineSegmentValue}
|
|
||||||
onChange={(e) => setInlineSegmentValue(e.target.value)}
|
|
||||||
onPressEnter={(e) => {
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
saveInlineSegmentEdit();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Space style={{ marginTop: 8 }}>
|
|
||||||
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: isMeetingOwner ? 'text' : 'default' }}
|
|
||||||
onDoubleClick={isMeetingOwner ? (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
startInlineSegmentEdit(item);
|
|
||||||
} : undefined}
|
|
||||||
>
|
|
||||||
{item.text_content}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{transcriptVisibleCount < transcript.length ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '8px 0 20px' }}>
|
|
||||||
<Text type="secondary">
|
|
||||||
已渲染 {transcriptVisibleCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对话数据" style={{ marginTop: 80 }} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -591,9 +591,14 @@
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.transcript-wrapper {
|
.transcript-wrapper {
|
||||||
|
display: block;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-audio-toolbar {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-audio-player {
|
.preview-audio-player {
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -823,34 +828,4 @@
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览页面播放器移动端优化 */
|
|
||||||
.preview-audio-player {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-player-controls {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-progress-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-current-time {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-current-time::after {
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
border-right: 4px solid transparent;
|
|
||||||
border-top: 4px solid white;
|
|
||||||
bottom: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-slider-thumb::after {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { Layout, Space, Button, App, 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 { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||||
import MindMap from '../components/MindMap';
|
import MindMap from '../components/MindMap';
|
||||||
|
import AudioPlayerBar from '../components/AudioPlayerBar';
|
||||||
|
import TranscriptTimeline from '../components/TranscriptTimeline';
|
||||||
import tools from '../utils/tools';
|
import tools from '../utils/tools';
|
||||||
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
||||||
import './MeetingPreview.css';
|
import './MeetingPreview.css';
|
||||||
|
|
@ -16,6 +18,7 @@ const MeetingPreview = () => {
|
||||||
const { meeting_id } = useParams();
|
const { meeting_id } = useParams();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
|
const transcriptRefs = useRef([]);
|
||||||
const [meeting, setMeeting] = useState(null);
|
const [meeting, setMeeting] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
@ -27,6 +30,7 @@ const MeetingPreview = () => {
|
||||||
const [transcript, setTranscript] = useState([]);
|
const [transcript, setTranscript] = useState([]);
|
||||||
const [audioUrl, setAudioUrl] = useState('');
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
|
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
||||||
|
|
@ -35,8 +39,10 @@ const MeetingPreview = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMeeting(null);
|
setMeeting(null);
|
||||||
setTranscript([]);
|
setTranscript([]);
|
||||||
|
transcriptRefs.current = [];
|
||||||
setAudioUrl('');
|
setAudioUrl('');
|
||||||
setActiveSegmentIndex(-1);
|
setActiveSegmentIndex(-1);
|
||||||
|
setPlaybackRate(1);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
|
|
@ -132,6 +138,14 @@ const MeetingPreview = () => {
|
||||||
audioRef.current.play().catch(() => {});
|
audioRef.current.play().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSegmentIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transcriptRefs.current[activeSegmentIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, [activeSegmentIndex]);
|
||||||
|
|
||||||
const handleVerify = () => {
|
const handleVerify = () => {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
message.warning('请输入访问密码');
|
message.warning('请输入访问密码');
|
||||||
|
|
@ -274,39 +288,28 @@ const MeetingPreview = () => {
|
||||||
children: (
|
children: (
|
||||||
<div className="transcript-wrapper">
|
<div className="transcript-wrapper">
|
||||||
{audioUrl ? (
|
{audioUrl ? (
|
||||||
<div className="preview-audio-player">
|
<AudioPlayerBar
|
||||||
<audio
|
audioRef={audioRef}
|
||||||
ref={audioRef}
|
|
||||||
src={audioUrl}
|
src={audioUrl}
|
||||||
controls
|
playbackRate={playbackRate}
|
||||||
controlsList="nodownload noplaybackrate"
|
onPlaybackRateChange={setPlaybackRate}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
style={{ width: '100%' }}
|
showMoreButton={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{transcript.length ? (
|
<TranscriptTimeline
|
||||||
<div className="transcript-list">
|
transcript={transcript}
|
||||||
{transcript.map((segment, index) => (
|
visibleCount={transcript.length}
|
||||||
<div
|
currentHighlightIndex={activeSegmentIndex}
|
||||||
key={segment.segment_id}
|
onJumpToTime={(timeMs) => jumpToSegment({ start_time_ms: timeMs })}
|
||||||
className={`transcript-segment${activeSegmentIndex === index ? ' active' : ''}`}
|
transcriptRefs={transcriptRefs}
|
||||||
onClick={() => jumpToSegment(segment)}
|
maxHeight="520px"
|
||||||
>
|
getSpeakerColor={(speakerId) => {
|
||||||
<div className="segment-header">
|
const palette = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2', '#2f54eb', '#faad14'];
|
||||||
<span className="speaker-name">
|
return palette[(speakerId ?? 0) % palette.length];
|
||||||
<UserOutlined style={{ marginRight: 6 }} />
|
}}
|
||||||
{segment.speaker_tag || `发言人 ${segment.speaker_id}`}
|
emptyDescription="暂无转录内容"
|
||||||
</span>
|
/>
|
||||||
<span className="segment-time">{tools.formatDuration((segment.start_time_ms || 0) / 1000)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="segment-text">{segment.text_content}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-transcript">暂无转录内容</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -439,110 +439,6 @@ body {
|
||||||
width: calc(100% - 96px) !important;
|
width: calc(100% - 96px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meeting-audio-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-player {
|
|
||||||
flex: 1;
|
|
||||||
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: 999px;
|
|
||||||
background: #f5f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-button.ant-btn {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
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: #355171;
|
|
||||||
border-color: #c7d4e4;
|
|
||||||
background: #f8fafc !important;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-button.ant-btn:disabled {
|
|
||||||
color: #a1b1c4;
|
|
||||||
border-color: #e3e8f0;
|
|
||||||
background: #f8fafc !important;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-rate.ant-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
min-width: 68px;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-button .anticon {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-audio-toolbar-rate .anticon {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-tab-toolbar {
|
.console-tab-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue