feat: 添加 M4A 文件验证和音频播放错误处理
- 在 `MeetingAudioUploadSupport` 中添加 M4A 文件验证逻辑,确保文件可播放 - 更新前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 以处理音频播放错误,并显示相应的警告信息 - 在 `WebMvcConfig` 中配置 M4A 媒dev_na
parent
5aefcf8d7d
commit
6600d37757
|
|
@ -2,6 +2,8 @@ package com.imeeting.config;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
|
||||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
|
@ -16,6 +18,11 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
@Value("${unisbase.app.resource-prefix}")
|
@Value("${unisbase.app.resource-prefix}")
|
||||||
private String resourcePrefix;
|
private String resourcePrefix;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
|
||||||
|
configurer.mediaType("m4a", MediaType.parseMediaType("audio/mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ public class AndroidMeetingRealtimeController {
|
||||||
command == null ? null : command.getEnableItn(),
|
command == null ? null : command.getEnableItn(),
|
||||||
command == null ? null : command.getEnableTextRefine(),
|
command == null ? null : command.getEnableTextRefine(),
|
||||||
command == null ? null : command.getSaveAudio(),
|
command == null ? null : command.getSaveAudio(),
|
||||||
|
null,
|
||||||
command == null ? null : command.getHotWords()
|
command == null ? null : command.getHotWords()
|
||||||
);
|
);
|
||||||
CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile);
|
CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ public class CreateMeetingCommand {
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
private Long hotWordGroupId;
|
||||||
|
|
||||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||||
private String userPrompt;
|
private String userPrompt;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ public class CreateRealtimeMeetingCommand {
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
private Long hotWordGroupId;
|
||||||
|
|
||||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||||
private String userPrompt;
|
private String userPrompt;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
List.of()
|
List.of()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,6 @@ public interface MeetingRuntimeProfileResolver {
|
||||||
Boolean enableItn,
|
Boolean enableItn,
|
||||||
Boolean enableTextRefine,
|
Boolean enableTextRefine,
|
||||||
Boolean saveAudio,
|
Boolean saveAudio,
|
||||||
|
Long hotWordGroupId,
|
||||||
List<String> hotWords);
|
List<String> hotWords);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.SeekableByteChannel;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -31,6 +34,8 @@ public class MeetingAudioUploadSupport {
|
||||||
private static final Set<String> WAV_MIME_TYPES = Set.of("audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave");
|
private static final Set<String> WAV_MIME_TYPES = Set.of("audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave");
|
||||||
private static final Set<String> MP3_MIME_TYPES = Set.of("audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg");
|
private static final Set<String> MP3_MIME_TYPES = Set.of("audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg");
|
||||||
private static final Set<String> M4A_MIME_TYPES = Set.of("audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac", "video/mp4");
|
private static final Set<String> M4A_MIME_TYPES = Set.of("audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac", "video/mp4");
|
||||||
|
private static final Set<String> PLAYABLE_M4A_SAMPLE_ENTRY_TYPES = Set.of("mp4a");
|
||||||
|
private static final Set<String> MP4_CONTAINER_TYPES = Set.of("moov", "trak", "mdia", "minf", "stbl");
|
||||||
|
|
||||||
@Value("${unisbase.app.upload-path}")
|
@Value("${unisbase.app.upload-path}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
@ -52,9 +57,15 @@ public class MeetingAudioUploadSupport {
|
||||||
|
|
||||||
String storedFileName = UUID.randomUUID() + "." + extension;
|
String storedFileName = UUID.randomUUID() + "." + extension;
|
||||||
Path targetPath = stagingDir.resolve(storedFileName);
|
Path targetPath = stagingDir.resolve(storedFileName);
|
||||||
|
try {
|
||||||
try (InputStream inputStream = file.getInputStream()) {
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
|
validateStoredAudio(targetPath, extension);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Files.deleteIfExists(targetPath);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
return buildStagingAudioToken(storedFileName);
|
return buildStagingAudioToken(storedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,4 +209,121 @@ public class MeetingAudioUploadSupport {
|
||||||
}
|
}
|
||||||
return new String(header, startInclusive, endExclusive - startInclusive, StandardCharsets.US_ASCII);
|
return new String(header, startInclusive, endExclusive - startInclusive, StandardCharsets.US_ASCII);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateStoredAudio(Path audioPath, String extension) throws IOException {
|
||||||
|
if (!"m4a".equals(extension)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String sampleEntryType = resolveM4aSampleEntryType(audioPath);
|
||||||
|
if (!StringUtils.hasText(sampleEntryType)) {
|
||||||
|
throw new RuntimeException("当前 m4a 文件未找到可识别的音频轨道,无法在网页中播放,请转为 mp3、wav 或 AAC 编码的 m4a 后重试");
|
||||||
|
}
|
||||||
|
if (!PLAYABLE_M4A_SAMPLE_ENTRY_TYPES.contains(sampleEntryType)) {
|
||||||
|
throw new RuntimeException("当前 m4a 文件音频编码为 " + sampleEntryType + ",浏览器通常只支持 AAC(mp4a)编码,请转为 mp3、wav 或 AAC 编码的 m4a 后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveM4aSampleEntryType(Path audioPath) throws IOException {
|
||||||
|
try (SeekableByteChannel channel = Files.newByteChannel(audioPath, StandardOpenOption.READ)) {
|
||||||
|
return findM4aSampleEntryType(channel, 0, channel.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findM4aSampleEntryType(SeekableByteChannel channel, long start, long end) throws IOException {
|
||||||
|
long position = start;
|
||||||
|
while (position + 8 <= end) {
|
||||||
|
Mp4AtomHeader header = readAtomHeader(channel, position, end);
|
||||||
|
if (header == null || header.endPosition() <= position) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("stsd".equals(header.type())) {
|
||||||
|
return readSampleEntryType(channel, header.payloadPosition(), header.endPosition());
|
||||||
|
}
|
||||||
|
if (MP4_CONTAINER_TYPES.contains(header.type())) {
|
||||||
|
String nestedType = findM4aSampleEntryType(channel, header.payloadPosition(), header.endPosition());
|
||||||
|
if (StringUtils.hasText(nestedType)) {
|
||||||
|
return nestedType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position = header.endPosition();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mp4AtomHeader readAtomHeader(SeekableByteChannel channel, long position, long parentEnd) throws IOException {
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(16);
|
||||||
|
if (!readFully(channel, buffer, position, 8)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long size = Integer.toUnsignedLong(buffer.getInt());
|
||||||
|
String type = readFourCc(buffer);
|
||||||
|
long headerLength = 8;
|
||||||
|
if (size == 1) {
|
||||||
|
if (!readFully(channel, buffer, position + 8, 8)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
size = buffer.getLong();
|
||||||
|
headerLength = 16;
|
||||||
|
} else if (size == 0) {
|
||||||
|
size = parentEnd - position;
|
||||||
|
}
|
||||||
|
if (size < headerLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long endPosition = position + size;
|
||||||
|
if (endPosition > parentEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Mp4AtomHeader(type, position + headerLength, endPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readSampleEntryType(SeekableByteChannel channel, long payloadStart, long atomEnd) throws IOException {
|
||||||
|
if (payloadStart + 8 > atomEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ByteBuffer stsdHeader = ByteBuffer.allocate(8);
|
||||||
|
if (!readFully(channel, stsdHeader, payloadStart, 8)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long entryCount = Integer.toUnsignedLong(stsdHeader.getInt(4));
|
||||||
|
long entryPosition = payloadStart + 8;
|
||||||
|
for (long index = 0; index < entryCount && entryPosition + 8 <= atomEnd; index++) {
|
||||||
|
ByteBuffer entryHeader = ByteBuffer.allocate(8);
|
||||||
|
if (!readFully(channel, entryHeader, entryPosition, 8)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long entrySize = Integer.toUnsignedLong(entryHeader.getInt());
|
||||||
|
String entryType = readFourCc(entryHeader);
|
||||||
|
if (entrySize < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(entryType)) {
|
||||||
|
return entryType;
|
||||||
|
}
|
||||||
|
entryPosition += entrySize;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean readFully(SeekableByteChannel channel, ByteBuffer buffer, long position, int length) throws IOException {
|
||||||
|
buffer.clear();
|
||||||
|
buffer.limit(length);
|
||||||
|
channel.position(position);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
if (channel.read(buffer) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.flip();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readFourCc(ByteBuffer buffer) {
|
||||||
|
byte[] typeBytes = new byte[4];
|
||||||
|
buffer.get(typeBytes);
|
||||||
|
return new String(typeBytes, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Mp4AtomHeader(String type, long payloadPosition, long endPosition) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
null,
|
null,
|
||||||
command.getEnableTextRefine(),
|
command.getEnableTextRefine(),
|
||||||
null,
|
null,
|
||||||
|
command.getHotWordGroupId(),
|
||||||
command.getHotWords()
|
command.getHotWords()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -644,6 +645,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
command.getEnableItn(),
|
command.getEnableItn(),
|
||||||
command.getEnableTextRefine(),
|
command.getEnableTextRefine(),
|
||||||
command.getSaveAudio(),
|
command.getSaveAudio(),
|
||||||
|
command.getHotWordGroupId(),
|
||||||
command.getHotWords()
|
command.getHotWords()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
import com.imeeting.dto.biz.HotWordGroupVO;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||||
import com.imeeting.entity.biz.HotWord;
|
import com.imeeting.entity.biz.HotWord;
|
||||||
import com.imeeting.entity.biz.AsrModel;
|
import com.imeeting.entity.biz.AsrModel;
|
||||||
|
|
@ -10,6 +11,7 @@ import com.imeeting.entity.biz.PromptTemplate;
|
||||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
|
import com.imeeting.service.biz.HotWordGroupService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
|
@ -25,6 +27,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
|
|
||||||
private final AiModelService aiModelService;
|
private final AiModelService aiModelService;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
private final HotWordGroupService hotWordGroupService;
|
||||||
private final HotWordService hotWordService;
|
private final HotWordService hotWordService;
|
||||||
private final AsrModelMapper asrModelMapper;
|
private final AsrModelMapper asrModelMapper;
|
||||||
private final LlmModelMapper llmModelMapper;
|
private final LlmModelMapper llmModelMapper;
|
||||||
|
|
@ -41,6 +44,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
Boolean enableItn,
|
Boolean enableItn,
|
||||||
Boolean enableTextRefine,
|
Boolean enableTextRefine,
|
||||||
Boolean saveAudio,
|
Boolean saveAudio,
|
||||||
|
Long hotWordGroupId,
|
||||||
List<String> hotWords) {
|
List<String> hotWords) {
|
||||||
long resolvedTenantId = tenantId == null ? 0L : tenantId;
|
long resolvedTenantId = tenantId == null ? 0L : tenantId;
|
||||||
AiModelVO asrModel = resolveModel("ASR", asrModelId, resolvedTenantId);
|
AiModelVO asrModel = resolveModel("ASR", asrModelId, resolvedTenantId);
|
||||||
|
|
@ -54,7 +58,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
profile.setResolvedSummaryModelName(summaryModel.getModelName());
|
profile.setResolvedSummaryModelName(summaryModel.getModelName());
|
||||||
profile.setResolvedPromptId(promptTemplate.getId());
|
profile.setResolvedPromptId(promptTemplate.getId());
|
||||||
profile.setResolvedPromptName(promptTemplate.getTemplateName());
|
profile.setResolvedPromptName(promptTemplate.getTemplateName());
|
||||||
profile.setResolvedHotWordGroupId(resolveHotWordGroupId(promptTemplate, hotWords));
|
profile.setResolvedHotWordGroupId(resolveHotWordGroupId(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
|
||||||
profile.setResolvedMode(nonBlank(mode, "2pass"));
|
profile.setResolvedMode(nonBlank(mode, "2pass"));
|
||||||
profile.setResolvedLanguage(nonBlank(language, "auto"));
|
profile.setResolvedLanguage(nonBlank(language, "auto"));
|
||||||
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
|
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
|
||||||
|
|
@ -62,11 +66,18 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
|
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
|
||||||
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
|
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
|
||||||
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
|
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
|
||||||
profile.setResolvedHotWords(resolveHotWords(promptTemplate, hotWords));
|
profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long resolveHotWordGroupId(PromptTemplate promptTemplate, List<String> hotWords) {
|
private Long resolveHotWordGroupId(Long tenantId, PromptTemplate promptTemplate, Long hotWordGroupId, List<String> hotWords) {
|
||||||
|
if (hotWordGroupId != null) {
|
||||||
|
if (hotWordGroupId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
assertHotWordGroupAvailable(hotWordGroupId, tenantId);
|
||||||
|
return hotWordGroupId;
|
||||||
|
}
|
||||||
List<String> normalized = normalizeHotWords(hotWords);
|
List<String> normalized = normalizeHotWords(hotWords);
|
||||||
if (!normalized.isEmpty()) {
|
if (!normalized.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -74,7 +85,20 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
return promptTemplate == null ? null : promptTemplate.getHotWordGroupId();
|
return promptTemplate == null ? null : promptTemplate.getHotWordGroupId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> resolveHotWords(PromptTemplate promptTemplate, List<String> hotWords) {
|
private List<String> resolveHotWords(Long tenantId, PromptTemplate promptTemplate, Long hotWordGroupId, List<String> hotWords) {
|
||||||
|
if (hotWordGroupId != null) {
|
||||||
|
if (hotWordGroupId <= 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
assertHotWordGroupAvailable(hotWordGroupId, tenantId);
|
||||||
|
return hotWordService.listEnabledByGroupIdIgnoreTenant(hotWordGroupId).stream()
|
||||||
|
.map(HotWord::getWord)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(item -> !item.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
List<String> normalized = normalizeHotWords(hotWords);
|
List<String> normalized = normalizeHotWords(hotWords);
|
||||||
if (!normalized.isEmpty()) {
|
if (!normalized.isEmpty()) {
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|
@ -92,6 +116,15 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void assertHotWordGroupAvailable(Long hotWordGroupId, Long tenantId) {
|
||||||
|
boolean visible = hotWordGroupService.listVisibleOptions(tenantId).stream()
|
||||||
|
.map(HotWordGroupVO::getId)
|
||||||
|
.anyMatch(id -> Objects.equals(id, hotWordGroupId));
|
||||||
|
if (!visible) {
|
||||||
|
throw new RuntimeException("热词组不存在或不可用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> normalizeHotWords(List<String> hotWords) {
|
private List<String> normalizeHotWords(List<String> hotWords) {
|
||||||
return hotWords == null ? List.of() : hotWords.stream()
|
return hotWords == null ? List.of() : hotWords.stream()
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,26 @@ class MeetingAccessServiceImplTest {
|
||||||
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
|
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsParticipantToViewAndExportButNotEdit() {
|
||||||
|
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
|
||||||
|
meeting.setParticipants("201,202,203");
|
||||||
|
LoginUser participant = new LoginUser(202L, 100L, "participant", false, false, null);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> service.assertCanViewMeeting(meeting, participant));
|
||||||
|
assertDoesNotThrow(() -> service.assertCanExportMeeting(meeting, participant));
|
||||||
|
assertThrows(RuntimeException.class, () -> service.assertCanEditMeeting(meeting, participant));
|
||||||
|
assertThrows(RuntimeException.class, () -> service.assertCanManageRealtimeMeeting(meeting, participant));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsTenantAdminToEditMeeting() {
|
||||||
|
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
|
||||||
|
LoginUser tenantAdmin = new LoginUser(300L, 100L, "tenant-admin", false, true, null);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> service.assertCanEditMeeting(meeting, tenantAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
private Meeting buildMeeting(String meetingType, String meetingSource) {
|
private Meeting buildMeeting(String meetingType, String meetingSource) {
|
||||||
Meeting meeting = new Meeting();
|
Meeting meeting = new Meeting();
|
||||||
meeting.setTenantId(100L);
|
meeting.setTenantId(100L);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
import com.imeeting.dto.biz.HotWordGroupVO;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||||
import com.imeeting.entity.biz.AsrModel;
|
import com.imeeting.entity.biz.AsrModel;
|
||||||
import com.imeeting.entity.biz.HotWord;
|
import com.imeeting.entity.biz.HotWord;
|
||||||
|
|
@ -10,6 +11,7 @@ import com.imeeting.entity.biz.PromptTemplate;
|
||||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
|
import com.imeeting.service.biz.HotWordGroupService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -30,10 +32,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
|
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||||
|
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||||
HotWordService hotWordService = mock(HotWordService.class);
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||||
aiModelService,
|
aiModelService,
|
||||||
promptTemplateService,
|
promptTemplateService,
|
||||||
|
hotWordGroupService,
|
||||||
hotWordService,
|
hotWordService,
|
||||||
mock(AsrModelMapper.class),
|
mock(AsrModelMapper.class),
|
||||||
mock(LlmModelMapper.class)
|
mock(LlmModelMapper.class)
|
||||||
|
|
@ -55,6 +59,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
null,
|
null,
|
||||||
Boolean.TRUE,
|
Boolean.TRUE,
|
||||||
Boolean.TRUE,
|
Boolean.TRUE,
|
||||||
|
null,
|
||||||
Arrays.asList(" alpha ", "", "alpha", "beta", null)
|
Arrays.asList(" alpha ", "", "alpha", "beta", null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -78,10 +83,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
void resolveShouldRejectCrossTenantModel() {
|
void resolveShouldRejectCrossTenantModel() {
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||||
|
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||||
HotWordService hotWordService = mock(HotWordService.class);
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||||
aiModelService,
|
aiModelService,
|
||||||
promptTemplateService,
|
promptTemplateService,
|
||||||
|
hotWordGroupService,
|
||||||
hotWordService,
|
hotWordService,
|
||||||
mock(AsrModelMapper.class),
|
mock(AsrModelMapper.class),
|
||||||
mock(LlmModelMapper.class)
|
mock(LlmModelMapper.class)
|
||||||
|
|
@ -101,6 +108,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
List.of()
|
List.of()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -109,10 +117,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
|
void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||||
|
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||||
HotWordService hotWordService = mock(HotWordService.class);
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||||
aiModelService,
|
aiModelService,
|
||||||
promptTemplateService,
|
promptTemplateService,
|
||||||
|
hotWordGroupService,
|
||||||
hotWordService,
|
hotWordService,
|
||||||
mock(AsrModelMapper.class),
|
mock(AsrModelMapper.class),
|
||||||
mock(LlmModelMapper.class)
|
mock(LlmModelMapper.class)
|
||||||
|
|
@ -142,6 +152,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
null,
|
null,
|
||||||
Boolean.FALSE,
|
Boolean.FALSE,
|
||||||
Boolean.FALSE,
|
Boolean.FALSE,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -153,12 +164,14 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
|
void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||||
|
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||||
HotWordService hotWordService = mock(HotWordService.class);
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||||
aiModelService,
|
aiModelService,
|
||||||
promptTemplateService,
|
promptTemplateService,
|
||||||
|
hotWordGroupService,
|
||||||
hotWordService,
|
hotWordService,
|
||||||
asrModelMapper,
|
asrModelMapper,
|
||||||
llmModelMapper
|
llmModelMapper
|
||||||
|
|
@ -184,6 +197,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
List.of()
|
List.of()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -191,6 +205,55 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
assertEquals(22L, profile.getResolvedSummaryModelId());
|
assertEquals(22L, profile.getResolvedSummaryModelId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveShouldPreferExplicitHotWordGroupOverTemplateBinding() {
|
||||||
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
|
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||||
|
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||||
|
aiModelService,
|
||||||
|
promptTemplateService,
|
||||||
|
hotWordGroupService,
|
||||||
|
hotWordService,
|
||||||
|
mock(AsrModelMapper.class),
|
||||||
|
mock(LlmModelMapper.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
|
||||||
|
when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
|
||||||
|
PromptTemplate template = enabledPrompt(33L, 1L, "Summary Prompt");
|
||||||
|
template.setHotWordGroupId(99L);
|
||||||
|
when(promptTemplateService.getById(33L)).thenReturn(template);
|
||||||
|
|
||||||
|
HotWordGroupVO explicitGroup = new HotWordGroupVO();
|
||||||
|
explicitGroup.setId(88L);
|
||||||
|
when(hotWordGroupService.listVisibleOptions(1L)).thenReturn(List.of(explicitGroup));
|
||||||
|
|
||||||
|
HotWord hotWord = new HotWord();
|
||||||
|
hotWord.setWord("override");
|
||||||
|
when(hotWordService.listEnabledByGroupIdIgnoreTenant(88L)).thenReturn(List.of(hotWord));
|
||||||
|
|
||||||
|
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
|
||||||
|
1L,
|
||||||
|
11L,
|
||||||
|
22L,
|
||||||
|
33L,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
88L,
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(88L, profile.getResolvedHotWordGroupId());
|
||||||
|
assertIterableEquals(List.of("override"), profile.getResolvedHotWords());
|
||||||
|
}
|
||||||
|
|
||||||
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
|
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
|
||||||
AiModelVO model = new AiModelVO();
|
AiModelVO model = new AiModelVO();
|
||||||
model.setId(id);
|
model.setId(id);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,23 @@ export interface MeetingVO {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUDIO_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
wav: "audio/wav",
|
||||||
|
m4a: "audio/mp4",
|
||||||
|
mp4: "audio/mp4",
|
||||||
|
aac: "audio/aac",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAudioMimeType = (audioUrl?: string) => {
|
||||||
|
if (!audioUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalizedUrl = audioUrl.split("#")[0]?.split("?")[0] || "";
|
||||||
|
const extension = normalizedUrl.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
|
||||||
|
return extension ? AUDIO_MIME_TYPE_BY_EXTENSION[extension] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CreateMeetingCommand {
|
export interface CreateMeetingCommand {
|
||||||
id?: number;
|
id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -49,6 +66,7 @@ export interface CreateMeetingCommand {
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
promptId: number;
|
promptId: number;
|
||||||
|
hotWordGroupId?: number;
|
||||||
userPrompt?: string;
|
userPrompt?: string;
|
||||||
useSpkId?: number;
|
useSpkId?: number;
|
||||||
enableTextRefine?: boolean;
|
enableTextRefine?: boolean;
|
||||||
|
|
@ -67,6 +85,7 @@ export interface CreateRealtimeMeetingCommand {
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
promptId: number;
|
promptId: number;
|
||||||
|
hotWordGroupId?: number;
|
||||||
userPrompt?: string;
|
userPrompt?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
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';
|
||||||
|
import { getHotWordGroupOptions, HotWordGroupVO } from '../../api/business/hotwordGroup';
|
||||||
import { listUsers, pageParams } from '../../api';
|
import { listUsers, pageParams } from '../../api';
|
||||||
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
|
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
|
||||||
import { SysUser } from '../../types';
|
import { SysUser } from '../../types';
|
||||||
|
|
@ -71,7 +72,9 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||||
|
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
|
||||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
|
const [hotWordGroupTouched, setHotWordGroupTouched] = useState(false);
|
||||||
|
|
||||||
const [audioUrl, setAudioUrl] = useState('');
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
@ -80,15 +83,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
|
|
||||||
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
||||||
const watchedPromptId = Form.useWatch("promptId", form);
|
const watchedPromptId = Form.useWatch("promptId", form);
|
||||||
|
const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form);
|
||||||
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
||||||
|
|
||||||
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
|
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
|
||||||
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
|
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
|
||||||
|
const selectedPrompt = useMemo(() => prompts.find((item) => item.id === watchedPromptId) || null, [prompts, watchedPromptId]);
|
||||||
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]);
|
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setType(initialType);
|
setType(initialType);
|
||||||
|
setHotWordGroupTouched(false);
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
setAudioUrl('');
|
setAudioUrl('');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
@ -96,14 +102,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
}
|
}
|
||||||
}, [open, initialType]);
|
}, [open, initialType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || hotWordGroupTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setFieldValue('hotWordGroupId', selectedPrompt?.hotWordGroupId);
|
||||||
|
}, [form, hotWordGroupTouched, open, selectedPrompt]);
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
||||||
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
|
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
|
||||||
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
||||||
getPromptPage({ current: 1, size: 100 }),
|
getPromptPage({ current: 1, size: 100 }),
|
||||||
getHotWordPage({ current: 1, size: 1000 }),
|
getHotWordPage({ current: 1, size: 1000 }),
|
||||||
|
getHotWordGroupOptions(),
|
||||||
listUsers(),
|
listUsers(),
|
||||||
getAiModelDefault("ASR"),
|
getAiModelDefault("ASR"),
|
||||||
getAiModelDefault("LLM"),
|
getAiModelDefault("LLM"),
|
||||||
|
|
@ -118,15 +132,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
setLlmModels(activeLlmModels);
|
setLlmModels(activeLlmModels);
|
||||||
setPrompts(activePrompts);
|
setPrompts(activePrompts);
|
||||||
setHotwordList(activeHotwords);
|
setHotwordList(activeHotwords);
|
||||||
|
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
|
||||||
setUserList(users || []);
|
setUserList(users || []);
|
||||||
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
|
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
|
||||||
|
const defaultPrompt = activePrompts.length > 0 ? activePrompts[0] : undefined;
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
||||||
meetingTime: dayjs(),
|
meetingTime: dayjs(),
|
||||||
asrModelId: defaultAsr.data.data?.id,
|
asrModelId: defaultAsr.data.data?.id,
|
||||||
summaryModelId: defaultLlm.data.data?.id,
|
summaryModelId: defaultLlm.data.data?.id,
|
||||||
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
promptId: defaultPrompt?.id,
|
||||||
|
hotWordGroupId: defaultPrompt?.hotWordGroupId,
|
||||||
useSpkId: 1,
|
useSpkId: 1,
|
||||||
enableTextRefine: false,
|
enableTextRefine: false,
|
||||||
mode: "2pass",
|
mode: "2pass",
|
||||||
|
|
@ -223,6 +240,12 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const { hostUserId, ...meetingValues } = values;
|
const { hostUserId, ...meetingValues } = values;
|
||||||
|
const selectedHotWords = meetingValues.hotWordGroupId == null || meetingValues.hotWordGroupId === 0
|
||||||
|
? undefined
|
||||||
|
: hotwordList
|
||||||
|
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId)
|
||||||
|
.map((item) => item.word)
|
||||||
|
.filter((word) => !!word?.trim());
|
||||||
|
|
||||||
if (type === 'upload') {
|
if (type === 'upload') {
|
||||||
await createMeeting({
|
await createMeeting({
|
||||||
|
|
@ -231,13 +254,16 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
audioUrl,
|
audioUrl,
|
||||||
participants: meetingValues.participants?.join(','),
|
participants: meetingValues.participants?.join(','),
|
||||||
tags: meetingValues.tags?.join(',')
|
tags: meetingValues.tags?.join(','),
|
||||||
|
hotWords: selectedHotWords
|
||||||
});
|
});
|
||||||
message.success('会议发起成功');
|
message.success('会议发起成功');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onCancel();
|
onCancel();
|
||||||
} else {
|
} else {
|
||||||
const selectedHotwords = hotwordList.map((item) => ({
|
const selectedHotwords = hotwordList
|
||||||
|
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
|
||||||
|
.map((item) => ({
|
||||||
hotword: item.word,
|
hotword: item.word,
|
||||||
weight: Number(item.weight || 2) / 10,
|
weight: Number(item.weight || 2) / 10,
|
||||||
}));
|
}));
|
||||||
|
|
@ -255,6 +281,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
enableItn: meetingValues.enableItn !== false,
|
enableItn: meetingValues.enableItn !== false,
|
||||||
enableTextRefine: !!meetingValues.enableTextRefine,
|
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||||
saveAudio: !!meetingValues.saveAudio,
|
saveAudio: !!meetingValues.saveAudio,
|
||||||
|
hotWords: selectedHotWords,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createRealtimeMeeting(payload);
|
const res = await createRealtimeMeeting(payload);
|
||||||
|
|
@ -426,6 +453,71 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<Form.Item
|
||||||
|
name="hotWordGroupId"
|
||||||
|
label="热词组"
|
||||||
|
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'}
|
||||||
|
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
|
||||||
|
size="large"
|
||||||
|
options={[
|
||||||
|
{ label: '不使用热词组', value: 0 },
|
||||||
|
...hotWordGroups.map((item) => ({
|
||||||
|
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
onChange={() => setHotWordGroupTouched(true)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="hotWordGroupId"
|
||||||
|
label="热词组"
|
||||||
|
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'}
|
||||||
|
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
|
||||||
|
size="large"
|
||||||
|
options={[
|
||||||
|
{ label: '不使用热词组', value: 0 },
|
||||||
|
...hotWordGroups.map((item) => ({
|
||||||
|
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
onChange={() => setHotWordGroupTouched(true)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="hotWordGroupId"
|
||||||
|
label={"\u70ed\u8bcd\u7ec4"}
|
||||||
|
tooltip={selectedPrompt?.hotWordGroupName ? `\u9ed8\u8ba4\u8ddf\u968f\u6a21\u677f\uff1a${selectedPrompt.hotWordGroupName}` : '\u6a21\u677f\u672a\u7ed1\u5b9a\u70ed\u8bcd\u7ec4\u65f6\u53ef\u624b\u52a8\u9009\u62e9'}
|
||||||
|
extra={watchedHotWordGroupId != null ? '\u521b\u5efa\u4f1a\u8bae\u65f6\u4f1a\u4f18\u5148\u4f7f\u7528\u8fd9\u91cc\u9009\u4e2d\u7684\u70ed\u8bcd\u7ec4' : undefined}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={selectedPrompt?.hotWordGroupId ? '\u9ed8\u8ba4\u5df2\u5e26\u51fa\u6a21\u677f\u70ed\u8bcd\u7ec4\uff0c\u53ef\u6309\u9700\u4fee\u6539' : '\u8bf7\u9009\u62e9\u70ed\u8bcd\u7ec4'}
|
||||||
|
size="large"
|
||||||
|
options={[
|
||||||
|
{ label: '\u4e0d\u4f7f\u7528\u70ed\u8bcd\u7ec4', value: 0 },
|
||||||
|
...hotWordGroups.map((item) => ({
|
||||||
|
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
onChange={() => setHotWordGroupTouched(true)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
expandIconPosition="end"
|
expandIconPosition="end"
|
||||||
|
|
@ -514,7 +606,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
<div>
|
<div>
|
||||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
|
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
|
||||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}>点击或拖拽录音文件到此处</p>
|
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}>点击或拖拽录音文件到此处</p>
|
||||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}>支持高质量 .mp3, .wav, .m4a 格式音频</p>
|
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}>支持 .mp3、.wav,以及 AAC 编码的 .m4a 音频</p>
|
||||||
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8, color: 'var(--app-text-secondary)' }}>文件大小不超过 {offlineAudioMaxSizeMb}MB,取值来自系统参数配置</p>
|
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8, color: 'var(--app-text-secondary)' }}>文件大小不超过 {offlineAudioMaxSizeMb}MB,取值来自系统参数配置</p>
|
||||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||||
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
MeetingTranscriptVO,
|
MeetingTranscriptVO,
|
||||||
MeetingVO,
|
MeetingVO,
|
||||||
reSummary,
|
reSummary,
|
||||||
|
resolveAudioMimeType,
|
||||||
retryMeetingTranscription,
|
retryMeetingTranscription,
|
||||||
updateMeetingBasic,
|
updateMeetingBasic,
|
||||||
updateMeetingTranscript,
|
updateMeetingTranscript,
|
||||||
|
|
@ -655,6 +656,8 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [shareSaving, setShareSaving] = useState(false);
|
const [shareSaving, setShareSaving] = useState(false);
|
||||||
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
||||||
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
||||||
|
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
|
||||||
|
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
@ -709,13 +712,26 @@ const MeetingDetail: React.FC = () => {
|
||||||
const profileStr = sessionStorage.getItem('userProfile');
|
const profileStr = sessionStorage.getItem('userProfile');
|
||||||
if (profileStr) {
|
if (profileStr) {
|
||||||
const profile = JSON.parse(profileStr);
|
const profile = JSON.parse(profileStr);
|
||||||
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
|
return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [meeting]);
|
}, [meeting]);
|
||||||
|
|
||||||
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
||||||
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||||
|
const emptyTranscriptFailureNotice = useMemo(() => {
|
||||||
|
if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '识别已结束,但没有生成可用转录',
|
||||||
|
description: canRetryTranscription
|
||||||
|
? 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。你可以直接重新识别,或先检查录音是否静音、时长过短、音量过低。'
|
||||||
|
: 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。建议先检查录音是否静音、时长过短、音量过低或音质异常。',
|
||||||
|
hint: canRetryTranscription ? '可以直接点击“重新识别”继续处理。' : '当前没有可重试的音频入口,请先确认原始录音文件是否有效。',
|
||||||
|
};
|
||||||
|
}, [canRetryTranscription, meeting, transcripts.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!meeting?.audioUrl) {
|
if (!meeting?.audioUrl) {
|
||||||
|
|
@ -788,6 +804,21 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meeting?.id || !emptyTranscriptFailureNotice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyTranscriptNoticeShownRef.current === meeting.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.warning({
|
||||||
|
content: emptyTranscriptFailureNotice.title,
|
||||||
|
duration: 4,
|
||||||
|
});
|
||||||
|
emptyTranscriptNoticeShownRef.current = meeting.id;
|
||||||
|
}, [emptyTranscriptFailureNotice, meeting?.id, message]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sharePopoverOpen) {
|
if (!sharePopoverOpen) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -797,39 +828,6 @@ const MeetingDetail: React.FC = () => {
|
||||||
setSharePasswordDraft(normalizedPassword);
|
setSharePasswordDraft(normalizedPassword);
|
||||||
}, [sharePopoverOpen, meeting?.accessPassword]);
|
}, [sharePopoverOpen, meeting?.accessPassword]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return undefined;
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
|
||||||
setAudioCurrentTime(audio.currentTime || 0);
|
|
||||||
audio.playbackRate = audioPlaybackRate;
|
|
||||||
};
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
setAudioCurrentTime(audio.currentTime || 0);
|
|
||||||
};
|
|
||||||
const handlePlay = () => setAudioPlaying(true);
|
|
||||||
const handlePause = () => setAudioPlaying(false);
|
|
||||||
const handleEnded = () => setAudioPlaying(false);
|
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
audio.addEventListener('play', handlePlay);
|
|
||||||
audio.addEventListener('pause', handlePause);
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
|
||||||
|
|
||||||
handleLoadedMetadata();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
audio.removeEventListener('play', handlePlay);
|
|
||||||
audio.removeEventListener('pause', handlePause);
|
|
||||||
audio.removeEventListener('ended', handleEnded);
|
|
||||||
};
|
|
||||||
}, [meeting?.audioUrl, audioPlaybackRate, meeting?.status]);
|
|
||||||
|
|
||||||
const loadAiConfigs = async () => {
|
const loadAiConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
const [modelRes, promptRes, defaultRes] = await Promise.all([
|
const [modelRes, promptRes, defaultRes] = await Promise.all([
|
||||||
|
|
@ -1078,6 +1076,62 @@ const MeetingDetail: React.FC = () => {
|
||||||
audioRef.current.play();
|
audioRef.current.play();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAudioPlaybackError = useCallback(() => {
|
||||||
|
const currentAudioUrl = meeting?.audioUrl || '';
|
||||||
|
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedUrl = currentAudioUrl.split('#')[0]?.split('?')[0]?.toLowerCase() || '';
|
||||||
|
const isM4a = normalizedUrl.endsWith('.m4a');
|
||||||
|
message.warning(
|
||||||
|
isM4a
|
||||||
|
? '当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav,或下载到本地播放。'
|
||||||
|
: '当前音频文件无法播放,请检查文件是否损坏或格式是否兼容。',
|
||||||
|
);
|
||||||
|
audioPlaybackErrorShownRef.current = currentAudioUrl;
|
||||||
|
setAudioPlaying(false);
|
||||||
|
}, [meeting?.audioUrl, message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return undefined;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||||
|
setAudioCurrentTime(audio.currentTime || 0);
|
||||||
|
audio.playbackRate = audioPlaybackRate;
|
||||||
|
};
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setAudioCurrentTime(audio.currentTime || 0);
|
||||||
|
};
|
||||||
|
const handlePlay = () => setAudioPlaying(true);
|
||||||
|
const handlePause = () => setAudioPlaying(false);
|
||||||
|
const handleEnded = () => setAudioPlaying(false);
|
||||||
|
const handleError = () => handleAudioPlaybackError();
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('durationchange', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('canplay', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
handleLoadedMetadata();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('durationchange', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('canplay', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
}, [meeting?.audioUrl, audioPlaybackRate, meeting?.status, handleAudioPlaybackError]);
|
||||||
|
|
||||||
const toggleAudioPlayback = () => {
|
const toggleAudioPlayback = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
if (audioRef.current.paused) {
|
if (audioRef.current.paused) {
|
||||||
|
|
@ -1413,7 +1467,6 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
{meeting.status === 1 ? (
|
{meeting.status === 1 ? (
|
||||||
<MeetingProgressDisplay
|
<MeetingProgressDisplay
|
||||||
|
|
@ -1427,8 +1480,9 @@ const MeetingDetail: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={24} style={{ height: '100%' }}>
|
<Row gutter={24} style={{ height: '100%' }}>
|
||||||
<Col xs={24} lg={14} style={{ height: '100%' }}>
|
<Col xs={24} lg={emptyTranscriptFailureNotice ? 24 : 14} style={{ height: '100%' }}>
|
||||||
<div ref={leftColumnRef} className="detail-side-column detail-left-column">
|
<div ref={leftColumnRef} className="detail-side-column detail-left-column">
|
||||||
|
{!emptyTranscriptFailureNotice && (
|
||||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||||
<div className="summary-head">
|
<div className="summary-head">
|
||||||
<div className="summary-title">
|
<div className="summary-title">
|
||||||
|
|
@ -1600,7 +1654,9 @@ const MeetingDetail: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!emptyTranscriptFailureNotice && (
|
||||||
<div className="section-divider-note">
|
<div className="section-divider-note">
|
||||||
<div className="section-divider-line" />
|
<div className="section-divider-line" />
|
||||||
<div className="section-divider-text">
|
<div className="section-divider-text">
|
||||||
|
|
@ -1608,10 +1664,21 @@ const MeetingDetail: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="section-divider-line" />
|
<div className="section-divider-line" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
||||||
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> 原文</span>}>
|
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> 原文</span>}>
|
||||||
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
|
{meeting.audioUrl && (
|
||||||
|
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
|
||||||
|
<source src={meeting.audioUrl} type={resolveAudioMimeType(meeting.audioUrl)} />
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
{emptyTranscriptFailureNotice && (
|
||||||
|
<div className="empty-transcript-inline-note">
|
||||||
|
<div className="empty-transcript-inline-note__title">当前没有可展示的转录内容</div>
|
||||||
|
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{meeting.audioSaveStatus === 'FAILED' && (
|
{meeting.audioSaveStatus === 'FAILED' && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
|
|
@ -1653,6 +1720,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
{!emptyTranscriptFailureNotice && (
|
||||||
<Col xs={24} lg={10} style={{ height: '100%' }}>
|
<Col xs={24} lg={10} style={{ height: '100%' }}>
|
||||||
<div className="detail-side-column ai-summary-column">
|
<div className="detail-side-column ai-summary-column">
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -1716,6 +1784,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1831,6 +1900,83 @@ const MeetingDetail: React.FC = () => {
|
||||||
radial-gradient(circle at top left, rgba(252, 208, 157, 0.18), transparent 26%),
|
radial-gradient(circle at top left, rgba(252, 208, 157, 0.18), transparent 26%),
|
||||||
linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 241, 231, 0.98));
|
linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 241, 231, 0.98));
|
||||||
}
|
}
|
||||||
|
.empty-transcript-hero {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(220, 170, 77, 0.24);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255, 233, 188, 0.72), transparent 34%),
|
||||||
|
linear-gradient(135deg, rgba(255, 252, 245, 0.98), rgba(255, 245, 223, 0.98));
|
||||||
|
box-shadow: 0 22px 48px rgba(188, 148, 68, 0.14);
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(122, 80, 14, 0.08);
|
||||||
|
color: #9a6700;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__content {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__title {
|
||||||
|
color: #7a4f0f;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__description {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 760px;
|
||||||
|
color: #7a5b27;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #b26a00;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.empty-transcript-player {
|
||||||
|
margin-top: 16px;
|
||||||
|
max-width: 480px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
.empty-transcript-inline-note {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(220, 170, 77, 0.22);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(255, 245, 224, 0.92));
|
||||||
|
}
|
||||||
|
.empty-transcript-inline-note__title {
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty-transcript-inline-note__text {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #876537;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
.meeting-share-popover .ant-popover-inner-content {
|
.meeting-share-popover .ant-popover-inner-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -2508,6 +2654,13 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
.empty-transcript-hero__content {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.empty-transcript-hero__actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.meeting-share-card {
|
.meeting-share-card {
|
||||||
width: min(86vw, 292px);
|
width: min(86vw, 292px);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import ReactMarkdown from "react-markdown";
|
||||||
import {
|
import {
|
||||||
getMeetingPreviewAccess,
|
getMeetingPreviewAccess,
|
||||||
getPublicMeetingPreview,
|
getPublicMeetingPreview,
|
||||||
|
resolveAudioMimeType,
|
||||||
type MeetingTranscriptVO,
|
type MeetingTranscriptVO,
|
||||||
type MeetingVO,
|
type MeetingVO,
|
||||||
} from "../../api/business/meeting";
|
} from "../../api/business/meeting";
|
||||||
|
|
@ -163,6 +164,7 @@ export default function MeetingPreview() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
||||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
||||||
|
|
@ -411,6 +413,22 @@ export default function MeetingPreview() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAudioError = () => {
|
||||||
|
const currentAudioUrl = meeting?.audioUrl || "";
|
||||||
|
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedUrl = currentAudioUrl.split("#")[0]?.split("?")[0]?.toLowerCase() || "";
|
||||||
|
const isM4a = normalizedUrl.endsWith(".m4a");
|
||||||
|
message.warning(
|
||||||
|
isM4a
|
||||||
|
? "当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav,或下载到本地播放。"
|
||||||
|
: TEXT.audioUnavailable,
|
||||||
|
);
|
||||||
|
audioPlaybackErrorShownRef.current = currentAudioUrl;
|
||||||
|
setAudioPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
const renderMeetingTitle = (title?: string) => {
|
const renderMeetingTitle = (title?: string) => {
|
||||||
const safeTitle = title || TEXT.untitledMeeting;
|
const safeTitle = title || TEXT.untitledMeeting;
|
||||||
return safeTitle.split(/(\d+)/).map((part, index) =>
|
return safeTitle.split(/(\d+)/).map((part, index) =>
|
||||||
|
|
@ -885,15 +903,17 @@ export default function MeetingPreview() {
|
||||||
{meeting.audioUrl && (
|
{meeting.audioUrl && (
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={meeting.audioUrl}
|
|
||||||
onTimeUpdate={handleAudioTimeUpdate}
|
onTimeUpdate={handleAudioTimeUpdate}
|
||||||
onPlay={handleAudioPlay}
|
onPlay={handleAudioPlay}
|
||||||
onPause={handleAudioPause}
|
onPause={handleAudioPause}
|
||||||
onEnded={handleAudioEnded}
|
onEnded={handleAudioEnded}
|
||||||
onLoadedMetadata={handleAudioLoadedMetadata}
|
onLoadedMetadata={handleAudioLoadedMetadata}
|
||||||
|
onError={handleAudioError}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
preload="metadata"
|
preload="auto"
|
||||||
/>
|
>
|
||||||
|
<source src={meeting.audioUrl} type={resolveAudioMimeType(meeting.audioUrl)} />
|
||||||
|
</audio>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meeting.audioUrl && pageTab === 'transcript' ? (
|
{meeting.audioUrl && pageTab === 'transcript' ? (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue