feat: 添加实时会议状态处理和转录内容检查

- 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务
- 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况
- 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态
- 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑
dev_na
chenhao 2026-04-22 17:54:58 +08:00
parent 29551dfbe2
commit 8d0ef246f3
3 changed files with 293 additions and 46 deletions

View File

@ -103,9 +103,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>() List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId) .eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getStartTime)); .orderByAsc(MeetingTranscript::getStartTime));
asrText = transcripts.stream() asrText = buildTranscriptText(transcripts);
.map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent())
.collect(Collectors.joining("\n"));
} }
// Real-time meetings are created without audio files and without ASR tasks. // Real-time meetings are created without audio files and without ASR tasks.
@ -123,6 +121,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
.eq(AiTask::getTaskType, "SUMMARY") .eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId) .orderByDesc(AiTask::getId)
.last("limit 1")); .last("limit 1"));
if (asrText == null || asrText.isBlank()) {
failPendingSummaryTask(sumTask, "No transcript content available for summary");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
return;
}
if (sumTask != null && sumTask.getStatus() == 0) { if (sumTask != null && sumTask.getStatus() == 0) {
processSummaryTask(meeting, asrText, sumTask); processSummaryTask(meeting, asrText, sumTask);
} else if (meeting.getStatus() != 3) { } else if (meeting.getStatus() != 3) {
@ -132,6 +136,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId)); redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId));
} catch (Exception e) { } catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e); log.error("Meeting {} AI Task Flow failed", meetingId, e);
failPendingSummaryTask(findLatestSummaryTask(meetingId), "Summary skipped because transcription failed: " + e.getMessage());
updateMeetingStatus(meetingId, 4); updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
} finally { } finally {
@ -148,24 +153,26 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private void doDispatchSummaryTask(Long meetingId) { private void doDispatchSummaryTask(Long meetingId) {
Meeting meeting = meetingMapper.selectById(meetingId); Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) return; if (meeting == null) return;
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("limit 1"));
try { try {
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>() List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId) .eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getStartTime)); .orderByAsc(MeetingTranscript::getStartTime));
if (transcripts.isEmpty()) { if (transcripts.isEmpty()) {
failPendingSummaryTask(sumTask, "No transcript content available for summary");
throw new RuntimeException("没有找到可用的转录文本,无法生成总结"); throw new RuntimeException("没有找到可用的转录文本,无法生成总结");
} }
String asrText = transcripts.stream() String asrText = buildTranscriptText(transcripts);
.map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent()) if (asrText == null || asrText.isBlank()) {
.collect(Collectors.joining("\n")); failPendingSummaryTask(sumTask, "No transcript content available for summary");
throw new RuntimeException("No transcript content available for summary");
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>() }
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("limit 1"));
if (sumTask != null && sumTask.getStatus() == 0) { if (sumTask != null && sumTask.getStatus() == 0) {
processSummaryTask(meeting, asrText, sumTask); processSummaryTask(meeting, asrText, sumTask);
} }
@ -418,6 +425,47 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return (int) Math.round(node.asDouble() * 1000D); return (int) Math.round(node.asDouble() * 1000D);
} }
private String buildTranscriptText(List<MeetingTranscript> transcripts) {
if (transcripts == null || transcripts.isEmpty()) {
return "";
}
return transcripts.stream()
.filter(Objects::nonNull)
.map(this::formatTranscriptLine)
.filter(line -> line != null && !line.isBlank())
.collect(Collectors.joining("\n"));
}
private String formatTranscriptLine(MeetingTranscript transcript) {
String content = transcript.getContent();
if (content == null || content.isBlank()) {
return null;
}
String speaker = transcript.getSpeakerName();
if (speaker == null || speaker.isBlank()) {
speaker = transcript.getSpeakerId();
}
if (speaker == null || speaker.isBlank()) {
return content.trim();
}
return speaker.trim() + ": " + content.trim();
}
private void failPendingSummaryTask(AiTask task, String error) {
if (task == null || Integer.valueOf(2).equals(task.getStatus()) || Integer.valueOf(3).equals(task.getStatus())) {
return;
}
updateAiTaskFail(task, error);
}
private AiTask findLatestSummaryTask(Long meetingId) {
return this.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("limit 1"));
}
private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception { private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception {
updateMeetingStatus(meeting.getId(), 2); updateMeetingStatus(meeting.getId(), 2);
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0); updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);

View File

@ -1,6 +1,9 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiModelService;
@ -10,12 +13,23 @@ import com.imeeting.support.TaskSecurityContextRunner;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI; import java.net.URI;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AiTaskServiceImplTest { class AiTaskServiceImplTest {
@ -60,18 +74,118 @@ class AiTaskServiceImplTest {
assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString()); assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString());
} }
@Test
void dispatchTasksShouldFailSummaryTaskWhenTranscriptContentIsBlank() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner()
));
doReturn(true).when(service).updateById(any());
Meeting meeting = new Meeting();
meeting.setId(66L);
meeting.setAudioUrl("/audio/demo.wav");
when(meetingMapper.selectById(66L)).thenReturn(meeting);
MeetingTranscript transcript = new MeetingTranscript();
transcript.setSpeakerName("Alice");
transcript.setContent(" ");
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
AiTask summaryTask = new AiTask();
summaryTask.setId(99L);
summaryTask.setMeetingId(66L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(null, summaryTask).when(service).getOne(any());
service.dispatchTasks(66L, 1L, 2L);
assertEquals(3, summaryTask.getStatus());
assertTrue(summaryTask.getErrorMsg().contains("No transcript content available for summary"));
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void dispatchSummaryTaskShouldFailWhenTranscriptContentIsBlank() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner()
));
doReturn(true).when(service).updateById(any());
Meeting meeting = new Meeting();
meeting.setId(77L);
when(meetingMapper.selectById(77L)).thenReturn(meeting);
MeetingTranscript transcript = new MeetingTranscript();
transcript.setSpeakerId("spk-1");
transcript.setContent(" ");
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
AiTask summaryTask = new AiTask();
summaryTask.setId(100L);
summaryTask.setMeetingId(77L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(summaryTask).when(service).getOne(any());
service.dispatchSummaryTask(77L, 1L, 2L);
assertEquals(3, summaryTask.getStatus());
assertTrue(summaryTask.getErrorMsg().contains("No transcript content available for summary"));
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
private AiTaskServiceImpl createService() { private AiTaskServiceImpl createService() {
return new AiTaskServiceImpl( return createService(
mock(MeetingMapper.class), mock(MeetingMapper.class),
mock(MeetingTranscriptMapper.class), mock(MeetingTranscriptMapper.class),
mock(AiModelService.class), mock(AiModelService.class),
new ObjectMapper(),
mock(SysUserMapper.class),
mock(HotWordService.class),
mock(StringRedisTemplate.class), mock(StringRedisTemplate.class),
mock(MeetingSummaryFileService.class),
mock(MeetingSummaryPromptAssembler.class),
mock(TaskSecurityContextRunner.class) mock(TaskSecurityContextRunner.class)
); );
} }
private AiTaskServiceImpl createService(MeetingMapper meetingMapper,
MeetingTranscriptMapper transcriptMapper,
AiModelService aiModelService,
StringRedisTemplate redisTemplate,
TaskSecurityContextRunner taskSecurityContextRunner) {
return new AiTaskServiceImpl(
meetingMapper,
transcriptMapper,
aiModelService,
new ObjectMapper(),
mock(SysUserMapper.class),
mock(HotWordService.class),
redisTemplate,
mock(MeetingSummaryFileService.class),
mock(MeetingSummaryPromptAssembler.class),
taskSecurityContextRunner
);
}
} }

View File

@ -57,6 +57,38 @@ import {SysUser} from '../../types';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const { Option } = Select; const { Option } = Select;
const PAUSED_DISPLAY_STATUS = 5; const PAUSED_DISPLAY_STATUS = 5;
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
const REALTIME_IDLE_DISPLAY_STATUS = 7;
const isRealtimeMeetingCandidate = (item: MeetingVO) => item.status === 0 && !item.audioUrl;
const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) =>
status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE';
const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"]) =>
status === 'PAUSED_EMPTY'
|| status === 'PAUSED_RESUMABLE'
|| status === 'ACTIVE'
|| status === 'IDLE';
const shouldPollMeetingCard = (item: MeetingVO) =>
item.status === 1
|| item.status === 2
|| item.realtimeSessionStatus === 'ACTIVE'
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
const canManageRealtimeMeeting = (meeting: MeetingVO) => {
try {
const profileStr = sessionStorage.getItem('userProfile');
if (!profileStr) {
return false;
}
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId;
} catch {
return false;
}
};
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => { const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
if (!sessionStatus) { if (!sessionStatus) {
@ -72,14 +104,14 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
if (sessionStatus.status === 'ACTIVE') { if (sessionStatus.status === 'ACTIVE') {
return { return {
...item, ...item,
displayStatus: 1, displayStatus: REALTIME_ACTIVE_DISPLAY_STATUS,
realtimeSessionStatus: sessionStatus.status realtimeSessionStatus: sessionStatus.status
}; };
} }
if (sessionStatus.status === 'IDLE' && !item.audioUrl) { if (sessionStatus.status === 'IDLE' && isRealtimeMeetingCandidate(item)) {
return { return {
...item, ...item,
displayStatus: 0, displayStatus: REALTIME_IDLE_DISPLAY_STATUS,
realtimeSessionStatus: sessionStatus.status realtimeSessionStatus: sessionStatus.status
}; };
} }
@ -93,8 +125,7 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null); const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => { useEffect(() => {
const effectiveStatus = meeting.displayStatus ?? meeting.status; if (meeting.status !== 1 && meeting.status !== 2) return;
if (effectiveStatus !== 1 && effectiveStatus !== 2) return;
const fetchProgress = async () => { const fetchProgress = async () => {
try { try {
const res = await getMeetingProgress(meeting.id); const res = await getMeetingProgress(meeting.id);
@ -109,7 +140,7 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
fetchProgress(); fetchProgress();
const timer = setInterval(fetchProgress, 3000); const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [meeting.id, meeting.status, meeting.displayStatus]); }, [meeting.id, meeting.status]);
return progress; return progress;
}; };
@ -122,11 +153,13 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' },
6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' },
7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' }
}; };
const config = statusConfig[effectiveStatus] || statusConfig[0]; const config = statusConfig[effectiveStatus] || statusConfig[0];
const percent = progress?.percent || 0; const percent = (meeting.status === 1 || meeting.status === 2) ? (progress?.percent || 0) : 0;
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; const isProcessing = meeting.status === 1 || meeting.status === 2;
return ( return (
<div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}> <div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}>
{isProcessing && percent > 0 && ( {isProcessing && percent > 0 && (
@ -151,8 +184,10 @@ const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> =
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => { const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
const progress = useMeetingProgress(item, () => fetchData()); const progress = useMeetingProgress(item, () => fetchData());
const effectiveStatus = item.displayStatus ?? item.status; const effectiveStatus = item.displayStatus ?? item.status;
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; const isProcessing = item.status === 1 || item.status === 2;
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
return ( return (
<List.Item> <List.Item>
@ -164,7 +199,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip> <Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
<Popconfirm <Popconfirm
title="确定删除?" title="确定删除?"
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }} onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(() => fetchData()); }}
okText={t('common.confirm')} okText={t('common.confirm')}
cancelText={t('common.cancel')} cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}
@ -231,6 +266,44 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
</Text> </Text>
</div> </div>
) : isRealtimeActive ? (
<div style={{
fontSize: '12px',
color: '#1677ff',
display: 'flex',
alignItems: 'center',
background: '#e6f4ff',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%',
boxSizing: 'border-box',
overflow: 'hidden'
}}>
<SyncOutlined spin style={{ marginRight: 6, flexShrink: 0 }} />
<Text ellipsis style={{ color: 'inherit', fontSize: '12px', fontWeight: 500, flex: 1, minWidth: 0 }}>
</Text>
</div>
) : isRealtimeIdle ? (
<div style={{
fontSize: '12px',
color: '#595959',
display: 'flex',
alignItems: 'center',
background: '#f5f5f5',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%',
boxSizing: 'border-box',
overflow: 'hidden'
}}>
<InfoCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
<Text ellipsis style={{ color: 'inherit', fontSize: '12px', fontWeight: 500, flex: 1, minWidth: 0 }}>
</Text>
</div>
) : ( ) : (
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10, flexShrink: 0 }} /><Text type="secondary" ellipsis style={{ flex: 1, minWidth: 0 }}>{item.participants || '无参与人员'}</Text></div> <div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10, flexShrink: 0 }} /><Text type="secondary" ellipsis style={{ flex: 1, minWidth: 0 }}>{item.participants || '无参与人员'}</Text></div>
@ -271,10 +344,7 @@ const Meetings: React.FC = () => {
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null); const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
const [participantsEditLoading, setParticipantsEditLoading] = useState(false); const [participantsEditLoading, setParticipantsEditLoading] = useState(false);
const [participantsEditForm] = Form.useForm(); const [participantsEditForm] = Form.useForm();
const hasRunningTasks = data.some(item => { const hasRunningTasks = data.some((item) => shouldPollMeetingCard(item));
const effectiveStatus = item.displayStatus ?? item.status;
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
});
const handleDisplayModeChange = (mode: 'card' | 'list') => { const handleDisplayModeChange = (mode: 'card' | 'list') => {
setDisplayMode(mode); setDisplayMode(mode);
@ -309,10 +379,13 @@ const Meetings: React.FC = () => {
if (res.data && res.data.data) { if (res.data && res.data.data) {
const records = res.data.data.records || []; const records = res.data.data.records || [];
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {}; let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
try { const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id)); if (realtimeCandidates.length > 0) {
statusMap = sessionRes.data?.data || {}; try {
} catch {} const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id));
statusMap = sessionRes.data?.data || {};
} catch {}
}
const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])); const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
setData(withDisplayStatus); setData(withDisplayStatus);
setTotal(res.data.data.total); setTotal(res.data.data.total);
@ -321,15 +394,25 @@ const Meetings: React.FC = () => {
}; };
const handleOpenMeeting = async (meeting: MeetingVO) => { const handleOpenMeeting = async (meeting: MeetingVO) => {
if (!isRealtimeMeetingCandidate(meeting)) {
navigate(`/meetings/${meeting.id}`);
return;
}
if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) {
navigate(`/meeting-live-session/${meeting.id}`);
return;
}
if (!canManageRealtimeMeeting(meeting)) {
navigate(`/meetings/${meeting.id}`);
return;
}
try { try {
const res = await getRealtimeMeetingSessionStatus(meeting.id); const res = await getRealtimeMeetingSessionStatus(meeting.id);
const sessionStatus = res.data?.data; const sessionStatus = res.data?.data?.status;
if (sessionStatus && !meeting.audioUrl && ( if (canOpenRealtimeSession(sessionStatus)) {
sessionStatus.status === 'PAUSED_EMPTY'
|| sessionStatus.status === 'PAUSED_RESUMABLE'
|| sessionStatus.status === 'ACTIVE'
|| sessionStatus.status === 'IDLE'
)) {
navigate(`/meeting-live-session/${meeting.id}`); navigate(`/meeting-live-session/${meeting.id}`);
return; return;
} }
@ -371,7 +454,9 @@ const Meetings: React.FC = () => {
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' },
6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' },
7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' }
}; };
const tableColumns = [ const tableColumns = [
@ -415,7 +500,7 @@ const Meetings: React.FC = () => {
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); openEditParticipants(record); }}></Button> <Button type="link" size="small" onClick={(e) => { e.stopPropagation(); openEditParticipants(record); }}></Button>
<Popconfirm <Popconfirm
title="确定删除?" title="确定删除?"
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(fetchData); }} onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(() => fetchData()); }}
okText={t('common.confirm')} okText={t('common.confirm')}
cancelText={t('common.cancel')} cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}