feat(meeting-details): 增强会议详情页的异步任务状态展示

- 在转录状态模型中添加 message 字段,用于传递任务状态信息
- 重构异步会议服务,支持提示词模板变量替换(会议标题、时间、创建者、参会者)
- 改进前端状态管理,添加上传进度显示和 AI 总结引导轮询
- 优化任务状态轮询逻辑,避免重复请求并提供更流畅的用户体验
codex/dev
AlanPaine 2026-04-07 06:04:56 +00:00
parent ac9c2f5fd4
commit abc5342258
4 changed files with 412 additions and 64 deletions

View File

@ -262,7 +262,8 @@ def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[
return TranscriptionTaskStatus( return TranscriptionTaskStatus(
task_id=task_record.get('task_id'), task_id=task_record.get('task_id'),
status=task_record.get('status', 'pending') or 'pending', status=task_record.get('status', 'pending') or 'pending',
progress=int(task_record.get('progress') or 0) progress=int(task_record.get('progress') or 0),
message=task_record.get('message')
) )
def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int): def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int):
@ -499,8 +500,8 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, []) 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] attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
tags = _load_tags_map(cursor, [meeting]).get(meeting_id, []) tags = _load_tags_map(cursor, [meeting]).get(meeting_id, [])
transcription_task = _load_latest_task_record(cursor, meeting_id, 'transcription') transcription_task = transcription_service.get_meeting_transcription_status(meeting_id)
llm_task = _load_latest_task_record(cursor, meeting_id, 'llm') llm_task = async_meeting_service.get_meeting_llm_status(meeting_id)
overall_status = _build_meeting_overall_status(transcription_task, llm_task) overall_status = _build_meeting_overall_status(transcription_task, llm_task)
cursor.close() cursor.close()

View File

@ -5,6 +5,7 @@
import uuid import uuid
import time import time
import os import os
import re
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from pathlib import Path from pathlib import Path
@ -96,7 +97,7 @@ class AsyncMeetingService:
# 3. 构建提示词 # 3. 构建提示词
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...") self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id) full_prompt = self._build_prompt(meeting_id, transcript_text, user_prompt, prompt_id)
# 4. 调用LLM API支持指定模型 # 4. 调用LLM API支持指定模型
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...") self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
@ -345,23 +346,128 @@ class AsyncMeetingService:
print(f"获取会议转录内容错误: {e}") print(f"获取会议转录内容错误: {e}")
return "" return ""
def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str: def _get_meeting_prompt_context(self, meeting_id: int) -> Dict[str, Any]:
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT m.title, m.meeting_time, u.caption AS creator_name
FROM meetings m
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE m.meeting_id = %s
""",
(meeting_id,)
)
meeting = cursor.fetchone()
if not meeting:
cursor.close()
return {}
meeting_time = meeting.get('meeting_time')
meeting_time_text = ''
if isinstance(meeting_time, datetime):
meeting_time_text = meeting_time.strftime('%Y-%m-%d %H:%M:%S')
elif meeting_time:
meeting_time_text = str(meeting_time)
cursor.execute(
"""
SELECT u.caption
FROM attendees a
JOIN sys_users u ON a.user_id = u.user_id
WHERE a.meeting_id = %s
ORDER BY u.caption ASC
""",
(meeting_id,)
)
attendee_rows = cursor.fetchall()
attendee_names = [row.get('caption') for row in attendee_rows if row.get('caption')]
attendee_text = ''.join(attendee_names)
cursor.close()
return {
'title': meeting.get('title') or '',
'meeting_time': meeting_time_text,
'meeting_time_value': meeting_time,
'creator_name': meeting.get('creator_name') or '',
'attendees': attendee_text
}
except Exception as e:
print(f"获取会议提示词上下文错误: {e}")
return {}
def _format_meeting_time_value(self, meeting_time_value: Any, custom_format: Optional[str] = None) -> str:
default_format = '%Y-%m-%d %H:%M:%S'
if isinstance(meeting_time_value, datetime):
try:
return meeting_time_value.strftime(custom_format or default_format)
except Exception:
return meeting_time_value.strftime(default_format)
if meeting_time_value:
meeting_time_text = str(meeting_time_value)
if custom_format:
try:
parsed_time = datetime.fromisoformat(meeting_time_text.replace('Z', '+00:00'))
return parsed_time.strftime(custom_format)
except Exception:
return meeting_time_text
return meeting_time_text
return ''
def _apply_prompt_variables(self, template: str, variables: Dict[str, Any]) -> str:
if not template:
return template
rendered = re.sub(
r"\{\{\s*meeting_time\s*:\s*([^{}]+?)\s*\}\}",
lambda match: self._format_meeting_time_value(
variables.get('meeting_time_value'),
match.group(1).strip()
),
template
)
for key, value in variables.items():
if key == 'meeting_time_value':
continue
rendered = rendered.replace(f"{{{{{key}}}}}", value or '')
rendered = rendered.replace(f"{{{{ {key} }}}}", value or '')
return rendered
def _build_prompt(self, meeting_id: int, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
""" """
构建完整的提示词 构建完整的提示词
使用数据库中配置的MEETING_TASK提示词模板 使用数据库中配置的MEETING_TASK提示词模板
Args: Args:
meeting_id: 会议ID
transcript_text: 会议转录文本 transcript_text: 会议转录文本
user_prompt: 用户额外提示词 user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版ID如果不指定则使用默认模版 prompt_id: 可选的提示词模版ID如果不指定则使用默认模版
""" """
# 从数据库获取会议任务的提示词模板支持指定prompt_id # 从数据库获取会议任务的提示词模板支持指定prompt_id
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id) system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
meeting_context = self._get_meeting_prompt_context(meeting_id)
prompt_variables = {
'meeting_id': str(meeting_id),
'meeting_title': meeting_context.get('title', ''),
'meeting_time': meeting_context.get('meeting_time', ''),
'meeting_creator': meeting_context.get('creator_name', ''),
'meeting_attendees': meeting_context.get('attendees', ''),
'meeting_time_value': meeting_context.get('meeting_time_value')
}
system_prompt = self._apply_prompt_variables(system_prompt, prompt_variables)
rendered_user_prompt = self._apply_prompt_variables(user_prompt, prompt_variables) if user_prompt else ''
prompt = f"{system_prompt}\n\n" prompt = f"{system_prompt}\n\n"
if user_prompt: if rendered_user_prompt:
prompt += f"用户额外要求:{user_prompt}\n\n" prompt += f"用户额外要求:{rendered_user_prompt}\n\n"
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:" prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"

View File

@ -0,0 +1,112 @@
# 会议总结模板变量说明
## 概述
会议总结提示词模板支持使用变量占位符。只有在模板内容中显式写入变量时,系统才会替换对应值;未使用的变量不会自动追加到提示词中。
## 支持的变量
### 1. 会议 ID
```text
{{meeting_id}}
```
输出当前会议的唯一 ID。
### 2. 会议标题
```text
{{meeting_title}}
```
输出当前会议标题。
### 3. 会议时间
默认格式输出:
```text
{{meeting_time}}
```
默认格式示例:
```text
2026-04-07 14:30:00
```
自定义格式输出:
```text
{{meeting_time:%Y年%m月%d日 %H:%M}}
```
自定义格式示例:
```text
2026年04月07日 14:30
```
如果未设置自定义格式,则使用默认格式。若自定义格式无法解析,系统会回退到默认时间值。
### 4. 会议创建人
```text
{{meeting_creator}}
```
输出会议创建人名称。
### 5. 参会人员
```text
{{meeting_attendees}}
```
输出参会人员名称列表,多个名字使用 `、` 连接。
## 可直接复制的变量列表
```text
{{meeting_id}}
{{meeting_title}}
{{meeting_time}}
{{meeting_time:%Y年%m月%d日 %H:%M}}
{{meeting_creator}}
{{meeting_attendees}}
```
## 模板示例
### 示例一:正式会议纪要
```text
请根据会议转录内容生成正式会议纪要。
会议标题:{{meeting_title}}
会议时间:{{meeting_time:%Y年%m月%d日 %H:%M}}
创建人:{{meeting_creator}}
参会人员:{{meeting_attendees}}
请输出:
1. 会议背景
2. 核心讨论
3. 决议事项
4. 待办事项
```
### 示例二:简洁总结
```text
请用简洁风格总结本次会议。
会议:{{meeting_title}}
时间:{{meeting_time}}
```
## 使用规则
- 变量必须使用双大括号包裹
- 仅当模板中写入变量时,系统才会替换对应内容
- `meeting_time` 支持自定义时间格式
- 未填写的字段可能返回空字符串

View File

@ -107,6 +107,8 @@ const MeetingDetails = ({ user }) => {
const [editDrawerOpen, setEditDrawerOpen] = useState(false); const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [showQRModal, setShowQRModal] = useState(false); const [showQRModal, setShowQRModal] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatusMessage, setUploadStatusMessage] = useState('');
const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
@ -131,11 +133,16 @@ const MeetingDetails = ({ user }) => {
const transcriptRefs = useRef([]); const transcriptRefs = useRef([]);
const statusCheckIntervalRef = useRef(null); const statusCheckIntervalRef = useRef(null);
const summaryPollIntervalRef = useRef(null); const summaryPollIntervalRef = useRef(null);
const summaryBootstrapTimeoutRef = useRef(null);
const activeSummaryTaskIdRef = useRef(null); const activeSummaryTaskIdRef = useRef(null);
const isMeetingOwner = user?.user_id === meeting?.creator_id; const isMeetingOwner = user?.user_id === meeting?.creator_id;
const creatorName = meeting?.creator_username || '未知创建人'; const creatorName = meeting?.creator_username || '未知创建人';
const hasUploadedAudio = Boolean(audioUrl); const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const isSummaryRunning = summaryLoading;
const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100));
const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100));
const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100));
const summaryDisabledReason = isUploading const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结' ? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio : !hasUploadedAudio
@ -153,6 +160,7 @@ const MeetingDetails = ({ user }) => {
return () => { return () => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
}; };
}, [meeting_id]); }, [meeting_id]);
@ -192,6 +200,103 @@ const MeetingDetails = ({ user }) => {
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [currentHighlightIndex, transcriptVisibleCount]); }, [currentHighlightIndex, transcriptVisibleCount]);
const clearSummaryBootstrapPolling = () => {
if (summaryBootstrapTimeoutRef.current) {
clearTimeout(summaryBootstrapTimeoutRef.current);
summaryBootstrapTimeoutRef.current = null;
}
};
const applyMeetingDetailsState = (meetingData, options = {}) => {
const { allowSummaryBootstrap = true } = options;
setMeeting(meetingData);
if (meetingData.prompt_id) {
setSelectedPromptId(meetingData.prompt_id);
}
setAccessPasswordEnabled(Boolean(meetingData.access_password));
setAccessPasswordDraft(meetingData.access_password || '');
if (meetingData.transcription_status) {
const ts = meetingData.transcription_status;
setTranscriptionStatus(ts);
setTranscriptionProgress(ts.progress || 0);
if (['pending', 'processing'].includes(ts.status) && ts.task_id) {
startStatusPolling(ts.task_id);
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (meetingData.llm_status) {
const llmStatus = meetingData.llm_status;
clearSummaryBootstrapPolling();
setSummaryTaskProgress(llmStatus.progress || 0);
setSummaryTaskMessage(
llmStatus.message
|| (llmStatus.status === 'processing'
? 'AI 正在分析会议内容...'
: llmStatus.status === 'pending'
? 'AI 总结任务排队中...'
: '')
);
if (['pending', 'processing'].includes(llmStatus.status) && llmStatus.task_id) {
startSummaryPolling(llmStatus.task_id);
} else {
setSummaryLoading(false);
}
} else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) {
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(true);
setSummaryTaskProgress(0);
setSummaryTaskMessage('转录完成,正在启动 AI 分析...');
}
if (allowSummaryBootstrap) {
scheduleSummaryBootstrapPolling();
}
} else {
clearSummaryBootstrapPolling();
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
}
const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5);
setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
};
const scheduleSummaryBootstrapPolling = (attempt = 0) => {
if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) {
return;
}
clearSummaryBootstrapPolling();
if (attempt >= 10) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
summaryBootstrapTimeoutRef.current = setTimeout(async () => {
summaryBootstrapTimeoutRef.current = null;
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
const meetingData = response.data;
applyMeetingDetailsState(meetingData, { allowSummaryBootstrap: false });
if (meetingData.llm_status || meetingData.summary) {
return;
}
} catch {
if (attempt >= 9) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
}
scheduleSummaryBootstrapPolling(attempt + 1);
}, attempt === 0 ? 1200 : 2000);
};
const fetchMeetingDetails = async (options = {}) => { const fetchMeetingDetails = async (options = {}) => {
const { showPageLoading = true } = options; const { showPageLoading = true } = options;
try { try {
@ -199,43 +304,11 @@ const MeetingDetails = ({ user }) => {
setLoading(true); setLoading(true);
} }
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
setMeeting(response.data); applyMeetingDetailsState(response.data);
if (response.data.prompt_id) { return response.data;
setSelectedPromptId(response.data.prompt_id);
}
setAccessPasswordEnabled(Boolean(response.data.access_password));
setAccessPasswordDraft(response.data.access_password || '');
if (response.data.transcription_status) {
const ts = response.data.transcription_status;
setTranscriptionStatus(ts);
setTranscriptionProgress(ts.progress || 0);
if (['pending', 'processing'].includes(ts.status)) {
startStatusPolling(ts.task_id);
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (response.data.llm_status) {
setSummaryTaskProgress(response.data.llm_status.progress || 0);
setSummaryTaskMessage(response.data.llm_status.message || '');
if (['pending', 'processing'].includes(response.data.llm_status.status)) {
startSummaryPolling(response.data.llm_status.task_id);
} else {
setSummaryLoading(false);
}
} else {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
const hasAudio = Boolean(response.data.audio_file_path && String(response.data.audio_file_path).length > 5);
setAudioUrl(hasAudio ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
} catch { } catch {
message.error('加载会议详情失败'); message.error('加载会议详情失败');
return null;
} finally { } finally {
if (showPageLoading) { if (showPageLoading) {
setLoading(false); setLoading(false);
@ -283,6 +356,7 @@ const MeetingDetails = ({ user }) => {
const status = res.data; const status = res.data;
setTranscriptionStatus(status); setTranscriptionStatus(status);
setTranscriptionProgress(status.progress || 0); setTranscriptionProgress(status.progress || 0);
setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev));
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval); clearInterval(interval);
statusCheckIntervalRef.current = null; statusCheckIntervalRef.current = null;
@ -348,7 +422,8 @@ const MeetingDetails = ({ user }) => {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
const status = statusRes.data; const status = statusRes.data;
setSummaryTaskProgress(status.progress || 0); setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || ''); setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...');
setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev));
if (status.status === 'completed') { if (status.status === 'completed') {
clearInterval(interval); clearInterval(interval);
@ -424,16 +499,33 @@ const MeetingDetails = ({ user }) => {
formData.append('model_code', selectedModelCode); formData.append('model_code', selectedModelCode);
} }
setIsUploading(true); setIsUploading(true);
setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...');
try { try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData); await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setUploadStatusMessage('正在上传音频文件...');
}
});
setUploadProgress(100);
setUploadStatusMessage('上传完成,正在启动转录任务...');
message.success('音频上传成功'); message.success('音频上传成功');
setTranscript([]); setTranscript([]);
setSpeakerList([]); setSpeakerList([]);
setEditingSpeakers({}); setEditingSpeakers({});
fetchMeetingDetails({ showPageLoading: false }); await fetchMeetingDetails({ showPageLoading: false });
fetchTranscript(); await fetchTranscript();
} catch { message.error('上传失败'); } } catch (error) {
finally { setIsUploading(false); } message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败');
throw error;
} finally {
setIsUploading(false);
setUploadProgress(0);
setUploadStatusMessage('');
}
}; };
const saveAccessPassword = async () => { const saveAccessPassword = async () => {
@ -727,7 +819,7 @@ const MeetingDetails = ({ user }) => {
]; ];
const audioMoreMenuItems = [ const audioMoreMenuItems = [
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription }, { key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || isUploading || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker }, { key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
]; ];
@ -801,14 +893,56 @@ const MeetingDetails = ({ user }) => {
</div> </div>
</Card> </Card>
{isUploading && (
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 16, border: '1px solid #91caff', background: 'linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%)' }}
styles={{ body: { padding: '14px 24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Space size={10}>
<UploadOutlined style={{ color: '#1677ff', fontSize: 16 }} />
<div>
<Text strong style={{ display: 'block' }}>音频上传中</Text>
<Text type="secondary">{uploadStatusMessage || '正在上传音频文件...'}</Text>
</div>
</Space>
<Text strong style={{ color: '#1677ff' }}>{displayUploadProgress}%</Text>
</div>
<Progress percent={displayUploadProgress} status="active" strokeColor={{ from: '#69b1ff', to: '#1677ff' }} />
</Card>
)}
{/* ── 转录进度条 ── */} {/* ── 转录进度条 ── */}
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && ( {transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}> <Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text> <Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
<Text>{transcriptionProgress}%</Text> <Text>{displayTranscriptionProgress}%</Text>
</div> </div>
<Progress percent={transcriptionProgress} status="active" size="small" /> <Progress percent={displayTranscriptionProgress} status="active" size="small" />
</Card>
)}
{isSummaryRunning && (
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 16, border: '1px solid #d3adf7', background: 'linear-gradient(135deg, #f9f0ff 0%, #ffffff 100%)' }}
styles={{ body: { padding: '14px 24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Space size={10}>
<RobotOutlined style={{ color: '#722ed1', fontSize: 16 }} />
<div>
<Text strong style={{ display: 'block' }}>AI 正在分析会议内容</Text>
<Text type="secondary">{summaryTaskMessage || '正在生成会议总结,请稍候...'}</Text>
</div>
</Space>
<Text strong style={{ color: '#722ed1' }}>{displaySummaryProgress}%</Text>
</div>
<Progress percent={displaySummaryProgress} status="active" strokeColor={{ from: '#b37feb', to: '#722ed1' }} />
</Card> </Card>
)} )}
@ -816,7 +950,14 @@ const MeetingDetails = ({ user }) => {
<Upload <Upload
id="audio-upload-input" id="audio-upload-input"
showUploadList={false} showUploadList={false}
customRequest={({ file }) => handleUploadAudio(file)} customRequest={async ({ file, onSuccess, onError }) => {
try {
await handleUploadAudio(file);
onSuccess?.({}, file);
} catch (error) {
onError?.(error);
}
}}
style={{ display: 'none' }} style={{ display: 'none' }}
> >
<span /> <span />
@ -1081,18 +1222,6 @@ const MeetingDetails = ({ user }) => {
/> />
)} )}
</div> </div>
{/* 总结生成中进度条 */}
{summaryLoading && (
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
<Card variant="borderless" style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
</div>
<Progress percent={summaryTaskProgress} status="active" size="small" />
</Card>
</div>
)}
</div> </div>
), ),
}, },