feat: 添加实时会议状态处理和转录内容检查
- 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务 - 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况 - 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态 - 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑dev_na
parent
29551dfbe2
commit
8d0ef246f3
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> = {};
|
||||||
|
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
|
||||||
|
if (realtimeCandidates.length > 0) {
|
||||||
try {
|
try {
|
||||||
const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id));
|
const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id));
|
||||||
statusMap = sessionRes.data?.data || {};
|
statusMap = sessionRes.data?.data || {};
|
||||||
} catch {}
|
} 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()}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue