feat: 添加实时会议配置选项和会话状态批量查询功能

- 在 `CreateRealtimeMeetingCommand` 中添加 `mode`, `language`, `enablePunctuation`, `enableItn`, `enableTextRefine`, 和 `saveAudio` 字段
- 更新 `MeetingCommandServiceImpl` 以支持新的实时会议配置选项
- 添加 `getRealtimeSessionStatuses` 接口,支持批量查询实时会议会话状态
- 更新前端API和组件,支持新的配置选项和批量查询功能
dev_na
chenhao 2026-04-03 14:38:36 +08:00
parent ff47c34349
commit 24c3835b79
11 changed files with 292 additions and 53 deletions

View File

@ -28,6 +28,7 @@ import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -50,6 +51,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -141,7 +143,7 @@ public class MeetingController {
@PostMapping @PostMapping
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> create(@RequestBody CreateMeetingCommand command) { public ApiResponse<MeetingVO> create(@Valid @RequestBody CreateMeetingCommand command) {
LoginUser loginUser = currentLoginUser(); LoginUser loginUser = currentLoginUser();
assertPromptAvailable(command.getPromptId(), loginUser); assertPromptAvailable(command.getPromptId(), loginUser);
return ApiResponse.ok(meetingCommandService.createMeeting( return ApiResponse.ok(meetingCommandService.createMeeting(
@ -154,7 +156,7 @@ public class MeetingController {
@PostMapping("/realtime/start") @PostMapping("/realtime/start")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> createRealtime(@RequestBody CreateRealtimeMeetingCommand command) { public ApiResponse<MeetingVO> createRealtime(@Valid @RequestBody CreateRealtimeMeetingCommand command) {
LoginUser loginUser = currentLoginUser(); LoginUser loginUser = currentLoginUser();
assertPromptAvailable(command.getPromptId(), loginUser); assertPromptAvailable(command.getPromptId(), loginUser);
return ApiResponse.ok(meetingCommandService.createRealtimeMeeting( return ApiResponse.ok(meetingCommandService.createRealtimeMeeting(
@ -235,6 +237,34 @@ public class MeetingController {
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id)); return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
} }
@PostMapping("/realtime/session-status/batch")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<Long, RealtimeMeetingSessionStatusVO>> getRealtimeSessionStatuses(@RequestBody List<Long> ids) {
LoginUser loginUser = currentLoginUser();
Map<Long, RealtimeMeetingSessionStatusVO> result = new LinkedHashMap<>();
if (ids == null || ids.isEmpty()) {
return ApiResponse.ok(result);
}
Map<Long, RealtimeMeetingSessionStatusVO> statuses = realtimeMeetingSessionStateService.getStatuses(ids);
for (Long id : ids) {
if (id == null) {
continue;
}
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
RealtimeMeetingSessionStatusVO status = statuses.get(id);
if (status != null) {
result.put(id, status);
}
} catch (RuntimeException ignored) {
// Preserve previous per-item fallback behavior for inaccessible meetings.
}
}
return ApiResponse.ok(result);
}
@PostMapping("/{id}/realtime/transcripts") @PostMapping("/{id}/realtime/transcripts")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) { public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {

View File

@ -1,6 +1,8 @@
package com.imeeting.dto.biz; package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -8,16 +10,22 @@ import java.util.List;
@Data @Data
public class CreateMeetingCommand { public class CreateMeetingCommand {
@NotBlank(message = "会议标题不能为空")
private String title; private String title;
@NotNull(message = "会议时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime; private LocalDateTime meetingTime;
private String participants; private String participants;
private String tags; private String tags;
@NotBlank(message = "音频地址不能为空")
private String audioUrl; private String audioUrl;
@NotNull(message = "识别模型不能为空")
private Long asrModelId; private Long asrModelId;
@NotNull(message = "总结模型不能为空")
private Long summaryModelId; private Long summaryModelId;
@NotNull(message = "提示词模板不能为空")
private Long promptId; private Long promptId;
private Integer useSpkId; private Integer useSpkId;
private Boolean enableTextRefine; private Boolean enableTextRefine;

View File

@ -1,6 +1,8 @@
package com.imeeting.dto.biz; package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -8,16 +10,27 @@ import java.util.List;
@Data @Data
public class CreateRealtimeMeetingCommand { public class CreateRealtimeMeetingCommand {
@NotBlank(message = "会议标题不能为空")
private String title; private String title;
@NotNull(message = "会议时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime; private LocalDateTime meetingTime;
private String participants; private String participants;
private String tags; private String tags;
@NotNull(message = "识别模型不能为空")
private Long asrModelId; private Long asrModelId;
@NotNull(message = "总结模型不能为空")
private Long summaryModelId; private Long summaryModelId;
@NotNull(message = "提示词模板不能为空")
private Long promptId; private Long promptId;
private String mode;
private String language;
private Integer useSpkId; private Integer useSpkId;
private Boolean enablePunctuation;
private Boolean enableItn;
private Boolean enableTextRefine;
private Boolean saveAudio;
private List<String> hotWords; private List<String> hotWords;
} }

View File

@ -3,6 +3,9 @@ package com.imeeting.service.biz;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import java.util.List;
import java.util.Map;
public interface RealtimeMeetingSessionStateService { public interface RealtimeMeetingSessionStateService {
void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId); void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId);
@ -14,6 +17,8 @@ public interface RealtimeMeetingSessionStateService {
RealtimeMeetingSessionStatusVO getStatus(Long meetingId); RealtimeMeetingSessionStatusVO getStatus(Long meetingId);
Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds);
RealtimeMeetingSessionStatusVO pause(Long meetingId); RealtimeMeetingSessionStatusVO pause(Long meetingId);
void pauseByDisconnect(Long meetingId, String connectionId); void pauseByDisconnect(Long meetingId, String connectionId);

View File

@ -7,6 +7,7 @@ import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
@ -25,9 +26,12 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -51,8 +55,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), tenantId, creatorId, creatorName, 0); command.getAudioUrl(), tenantId, creatorId, creatorName, 0);
meetingService.save(meeting); meetingService.save(meeting);
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
meetingService.updateById(meeting);
AiTask asrTask = new AiTask(); AiTask asrTask = new AiTask();
asrTask.setMeetingId(meeting.getId()); asrTask.setMeetingId(meeting.getId());
@ -78,6 +80,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
aiTaskService.save(asrTask); aiTaskService.save(asrTask);
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId()); meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
meetingService.updateById(meeting);
meetingDomainSupport.publishMeetingCreated(meeting.getId()); meetingDomainSupport.publishMeetingCreated(meeting.getId());
MeetingVO vo = new MeetingVO(); MeetingVO vo = new MeetingVO();
@ -89,10 +93,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, tenantId, creatorId, creatorName, 1); null, tenantId, creatorId, creatorName, 0);
meetingService.save(meeting); meetingService.save(meeting);
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId()); meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId); realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId));
MeetingVO vo = new MeetingVO(); MeetingVO vo = new MeetingVO();
meetingDomainSupport.fillMeetingVO(meeting, vo, false); meetingDomainSupport.fillMeetingVO(meeting, vo, false);
@ -270,4 +275,48 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
// Ignore progress write failures. // Ignore progress write failures.
} }
} }
private RealtimeMeetingResumeConfig buildRealtimeResumeConfig(CreateRealtimeMeetingCommand command, Long tenantId) {
RealtimeMeetingResumeConfig resumeConfig = new RealtimeMeetingResumeConfig();
resumeConfig.setAsrModelId(command.getAsrModelId());
resumeConfig.setMode(command.getMode() == null || command.getMode().isBlank() ? "2pass" : command.getMode().trim());
resumeConfig.setLanguage(command.getLanguage() == null || command.getLanguage().isBlank() ? "auto" : command.getLanguage().trim());
resumeConfig.setUseSpkId(command.getUseSpkId() != null ? command.getUseSpkId() : 0);
resumeConfig.setEnablePunctuation(command.getEnablePunctuation() != null ? command.getEnablePunctuation() : Boolean.TRUE);
resumeConfig.setEnableItn(command.getEnableItn() != null ? command.getEnableItn() : Boolean.TRUE);
resumeConfig.setEnableTextRefine(Boolean.TRUE.equals(command.getEnableTextRefine()));
resumeConfig.setSaveAudio(Boolean.TRUE.equals(command.getSaveAudio()));
resumeConfig.setHotwords(resolveRealtimeHotwords(command.getHotWords(), tenantId));
return resumeConfig;
}
private List<Map<String, Object>> resolveRealtimeHotwords(List<String> selectedWords, Long tenantId) {
List<HotWord> tenantHotwords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, tenantId)
.eq(HotWord::getStatus, 1));
if (tenantHotwords == null || tenantHotwords.isEmpty()) {
return List.of();
}
List<String> effectiveWords = selectedWords == null
? List.of()
: selectedWords.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(word -> !word.isEmpty())
.toList();
return tenantHotwords.stream()
.filter(item -> effectiveWords.isEmpty() || effectiveWords.contains(item.getWord()))
.map(this::toRealtimeHotword)
.collect(Collectors.toList());
}
private Map<String, Object> toRealtimeHotword(HotWord hotWord) {
Map<String, Object> item = new HashMap<>();
item.put("hotword", hotWord.getWord());
int rawWeight = hotWord.getWeight() == null ? 20 : hotWord.getWeight();
item.put("weight", BigDecimal.valueOf(rawWeight).divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue());
return item;
}
} }

View File

@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -28,6 +30,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@ -91,12 +94,28 @@ public class MeetingDomainSupport {
} }
try { try {
AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, audioUrl);
if (plan == null || !Files.exists(plan.sourcePath())) {
return audioUrl;
}
Files.createDirectories(plan.targetPath().getParent());
if (plan.backupPath() != null && Files.exists(plan.targetPath())) {
Files.move(plan.targetPath(), plan.backupPath(), StandardCopyOption.REPLACE_EXISTING);
}
Files.move(plan.sourcePath(), plan.targetPath(), StandardCopyOption.REPLACE_EXISTING);
registerAudioRelocationCompensation(meetingId, plan);
return plan.relocatedUrl();
} catch (Exception ex) {
log.error("Failed to move audio file for meeting {}", meetingId, ex);
throw new RuntimeException("File relocation failed: " + ex.getMessage());
}
}
private AudioRelocationPlan buildAudioRelocationPlan(Long meetingId, String audioUrl) {
String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1);
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path sourcePath = Paths.get(basePath, "audio", fileName); Path sourcePath = Paths.get(basePath, "audio", fileName);
if (!Files.exists(sourcePath)) {
return audioUrl;
}
String ext = ""; String ext = "";
int dotIdx = fileName.lastIndexOf('.'); int dotIdx = fileName.lastIndexOf('.');
@ -104,13 +123,60 @@ public class MeetingDomainSupport {
ext = fileName.substring(dotIdx); ext = fileName.substring(dotIdx);
} }
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId)); Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId));
Files.createDirectories(targetDir);
Path targetPath = targetDir.resolve("source_audio" + ext); Path targetPath = targetDir.resolve("source_audio" + ext);
Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); Path backupPath = Files.exists(targetPath)
return "/api/static/meetings/" + meetingId + "/source_audio" + ext; ? targetDir.resolve("source_audio" + ext + ".rollback-" + UUID.randomUUID() + ".bak")
: null;
return new AudioRelocationPlan(
sourcePath,
targetPath,
backupPath,
"/api/static/meetings/" + meetingId + "/source_audio" + ext
);
}
private void registerAudioRelocationCompensation(Long meetingId, AudioRelocationPlan plan) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
log.warn("Audio relocation compensation skipped because transaction synchronization is inactive, meetingId={}", meetingId);
cleanupBackupFile(plan.backupPath(), meetingId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_COMMITTED) {
cleanupBackupFile(plan.backupPath(), meetingId);
return;
}
compensateAudioRelocation(meetingId, plan);
}
});
}
private void compensateAudioRelocation(Long meetingId, AudioRelocationPlan plan) {
try {
if (Files.exists(plan.targetPath())) {
Files.createDirectories(plan.sourcePath().getParent());
Files.move(plan.targetPath(), plan.sourcePath(), StandardCopyOption.REPLACE_EXISTING);
}
if (plan.backupPath() != null && Files.exists(plan.backupPath())) {
Files.createDirectories(plan.targetPath().getParent());
Files.move(plan.backupPath(), plan.targetPath(), StandardCopyOption.REPLACE_EXISTING);
}
} catch (Exception ex) { } catch (Exception ex) {
log.error("Failed to move audio file for meeting {}", meetingId, ex); log.error("Failed to compensate audio relocation for meeting {}", meetingId, ex);
throw new RuntimeException("鏂囦欢澶勭悊澶辫触: " + ex.getMessage()); }
}
private void cleanupBackupFile(Path backupPath, Long meetingId) {
if (backupPath == null) {
return;
}
try {
Files.deleteIfExists(backupPath);
} catch (Exception ex) {
log.warn("Failed to clean audio relocation backup for meeting {}", meetingId, ex);
} }
} }
@ -196,4 +262,7 @@ public class MeetingDomainSupport {
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting)); vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
} }
} }
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
}
} }

View File

@ -18,6 +18,9 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service @Service
@Slf4j @Slf4j
@ -124,6 +127,21 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
return toStatusVO(state); return toStatusVO(state);
} }
@Override
public Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds) {
Map<Long, RealtimeMeetingSessionStatusVO> statuses = new LinkedHashMap<>();
if (meetingIds == null || meetingIds.isEmpty()) {
return statuses;
}
for (Long meetingId : meetingIds) {
if (meetingId == null) {
continue;
}
statuses.put(meetingId, getStatus(meetingId));
}
return statuses;
}
@Override @Override
public RealtimeMeetingSessionStatusVO pause(Long meetingId) { public RealtimeMeetingSessionStatusVO pause(Long meetingId) {
RealtimeMeetingSessionState state = getOrCreateState(meetingId); RealtimeMeetingSessionState state = getOrCreateState(meetingId);

View File

@ -44,6 +44,24 @@ export interface CreateMeetingCommand {
export type MeetingDTO = CreateMeetingCommand; export type MeetingDTO = CreateMeetingCommand;
export interface CreateRealtimeMeetingCommand {
title: string;
meetingTime: string;
participants: string;
tags: string;
asrModelId: number;
summaryModelId?: number;
promptId: number;
mode?: string;
language?: string;
useSpkId?: number;
enablePunctuation?: boolean;
enableItn?: boolean;
enableTextRefine?: boolean;
saveAudio?: boolean;
hotWords?: string[];
}
export interface UpdateMeetingBasicCommand { export interface UpdateMeetingBasicCommand {
meetingId: number; meetingId: number;
title?: string; title?: string;
@ -117,7 +135,7 @@ export interface RealtimeMeetingSessionStatus {
resumeConfig?: RealtimeSocketSessionRequest; resumeConfig?: RealtimeSocketSessionRequest;
} }
export const createRealtimeMeeting = (data: CreateMeetingCommand) => { export const createRealtimeMeeting = (data: CreateRealtimeMeetingCommand) => {
return http.post<any, { code: string; data: MeetingVO; msg: string }>( return http.post<any, { code: string; data: MeetingVO; msg: string }>(
"/api/biz/meeting/realtime/start", "/api/biz/meeting/realtime/start",
data data
@ -137,6 +155,13 @@ export const getRealtimeMeetingSessionStatus = (meetingId: number) => {
); );
}; };
export const getRealtimeMeetingSessionStatuses = (meetingIds: number[]) => {
return http.post<any, { code: string; data: Record<number, RealtimeMeetingSessionStatus>; msg: string }>(
"/api/biz/meeting/realtime/session-status/batch",
meetingIds
);
};
export const pauseRealtimeMeeting = (meetingId: number) => { export const pauseRealtimeMeeting = (meetingId: number) => {
return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>( return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
`/api/biz/meeting/${meetingId}/realtime/pause`, `/api/biz/meeting/${meetingId}/realtime/pause`,

View File

@ -10,7 +10,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus } from '../../api/business/meeting'; import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatuses, RealtimeMeetingSessionStatus } from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
@ -24,6 +24,37 @@ const { Dragger } = Upload;
const { Option } = Select; const { Option } = Select;
const PAUSED_DISPLAY_STATUS = 5; const PAUSED_DISPLAY_STATUS = 5;
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
if (!sessionStatus) {
return item;
}
if (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE') {
return {
...item,
displayStatus: PAUSED_DISPLAY_STATUS,
realtimeSessionStatus: sessionStatus.status
};
}
if (sessionStatus.status === 'ACTIVE') {
return {
...item,
displayStatus: 1,
realtimeSessionStatus: sessionStatus.status
};
}
if (sessionStatus.status === 'IDLE' && !item.audioUrl) {
return {
...item,
displayStatus: 0,
realtimeSessionStatus: sessionStatus.status
};
}
return {
...item,
realtimeSessionStatus: sessionStatus.status
};
};
// --- 进度感知 Hook --- // --- 进度感知 Hook ---
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);
@ -421,32 +452,12 @@ const Meetings: React.FC = () => {
const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
if (res.data && res.data.data) { if (res.data && res.data.data) {
const records = res.data.data.records || []; const records = res.data.data.records || [];
const withDisplayStatus = await Promise.all(records.map(async (item) => { let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
try { try {
const sessionRes = await getRealtimeMeetingSessionStatus(item.id); const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id));
const sessionStatus = sessionRes.data?.data; statusMap = sessionRes.data?.data || {};
if (sessionStatus?.status === 'PAUSED_EMPTY' || sessionStatus?.status === 'PAUSED_RESUMABLE') { } catch {}
return { const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
...item,
displayStatus: PAUSED_DISPLAY_STATUS,
realtimeSessionStatus: sessionStatus.status
};
}
if (sessionStatus?.status === 'ACTIVE') {
return {
...item,
displayStatus: 1,
realtimeSessionStatus: sessionStatus.status
};
}
return {
...item,
realtimeSessionStatus: sessionStatus?.status
};
} catch {
return item;
}
}));
setData(withDisplayStatus); setData(withDisplayStatus);
setTotal(res.data.data.total); setTotal(res.data.data.total);
} }
@ -457,7 +468,12 @@ const Meetings: React.FC = () => {
try { try {
const res = await getRealtimeMeetingSessionStatus(meeting.id); const res = await getRealtimeMeetingSessionStatus(meeting.id);
const sessionStatus = res.data?.data; const sessionStatus = res.data?.data;
if (sessionStatus && (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE' || sessionStatus.status === 'ACTIVE')) { if (sessionStatus && !meeting.audioUrl && (
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;
} }
@ -544,7 +560,7 @@ const Meetings: React.FC = () => {
setFileList([]); setFileList([]);
setCreateDrawerVisible(true); setCreateDrawerVisible(true);
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button> }} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>
{can("meeting:create:realtime") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>} {can("menu:meeting") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>}
</Space> </Space>
</Col> </Col>
</Row> </Row>

View File

@ -36,7 +36,7 @@ import { listUsers } from "../../api";
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel"; import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword"; import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt"; import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import { createRealtimeMeeting, type MeetingDTO } from "../../api/business/meeting"; import { createRealtimeMeeting, type CreateRealtimeMeetingCommand } from "../../api/business/meeting";
import type { SysUser } from "../../types"; import type { SysUser } from "../../types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -171,12 +171,18 @@ export default function RealtimeAsr() {
weight: Number(item.weight || 2) / 10, weight: Number(item.weight || 2) / 10,
})); }));
const payload: MeetingDTO = { const payload: CreateRealtimeMeetingCommand = {
...values, ...values,
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"), meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: values.participants?.join(",") || "", participants: values.participants?.join(",") || "",
tags: values.tags?.join(",") || "", tags: values.tags?.join(",") || "",
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId ? 1 : 0, useSpkId: values.useSpkId ? 1 : 0,
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotWords: values.hotWords, hotWords: values.hotWords,
}; };

View File

@ -151,7 +151,7 @@ export default function HomePage() {
<section className="home-recent-section"> <section className="home-recent-section">
<div className="home-section-header"> <div className="home-section-header">
<Title level={3}> / </Title> <Title level={3}></Title>
<Button type="link" onClick={() => navigate("/meetings")} className="home-view-all"> <Button type="link" onClick={() => navigate("/meetings")} className="home-view-all">
<ArrowRightOutlined /> <ArrowRightOutlined />
</Button> </Button>