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.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.WebMvcConfigurer;
|
||||
|
||||
|
|
@ -16,6 +18,11 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||
@Value("${unisbase.app.resource-prefix}")
|
||||
private String resourcePrefix;
|
||||
|
||||
@Override
|
||||
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
|
||||
configurer.mediaType("m4a", MediaType.parseMediaType("audio/mp4"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// 确保目录存在
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ public class AndroidMeetingRealtimeController {
|
|||
command == null ? null : command.getEnableItn(),
|
||||
command == null ? null : command.getEnableTextRefine(),
|
||||
command == null ? null : command.getSaveAudio(),
|
||||
null,
|
||||
command == null ? null : command.getHotWords()
|
||||
);
|
||||
CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ public class CreateMeetingCommand {
|
|||
@NotNull(message = "promptId must not be null")
|
||||
private Long promptId;
|
||||
|
||||
private Long hotWordGroupId;
|
||||
|
||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||
private String userPrompt;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ public class CreateRealtimeMeetingCommand {
|
|||
@NotNull(message = "promptId must not be null")
|
||||
private Long promptId;
|
||||
|
||||
private Long hotWordGroupId;
|
||||
|
||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||
private String userPrompt;
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ public interface MeetingRuntimeProfileResolver {
|
|||
Boolean enableItn,
|
||||
Boolean enableTextRefine,
|
||||
Boolean saveAudio,
|
||||
Long hotWordGroupId,
|
||||
List<String> hotWords);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
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> 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> 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}")
|
||||
private String uploadPath;
|
||||
|
|
@ -52,9 +57,15 @@ public class MeetingAudioUploadSupport {
|
|||
|
||||
String storedFileName = UUID.randomUUID() + "." + extension;
|
||||
Path targetPath = stagingDir.resolve(storedFileName);
|
||||
try {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
validateStoredAudio(targetPath, extension);
|
||||
} catch (Exception ex) {
|
||||
Files.deleteIfExists(targetPath);
|
||||
throw ex;
|
||||
}
|
||||
return buildStagingAudioToken(storedFileName);
|
||||
}
|
||||
|
||||
|
|
@ -198,4 +209,121 @@ public class MeetingAudioUploadSupport {
|
|||
}
|
||||
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,
|
||||
command.getEnableTextRefine(),
|
||||
null,
|
||||
command.getHotWordGroupId(),
|
||||
command.getHotWords()
|
||||
);
|
||||
}
|
||||
|
|
@ -644,6 +645,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
command.getEnableItn(),
|
||||
command.getEnableTextRefine(),
|
||||
command.getSaveAudio(),
|
||||
command.getHotWordGroupId(),
|
||||
command.getHotWords()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
|
|||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.HotWordGroupVO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||
import com.imeeting.entity.biz.HotWord;
|
||||
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.LlmModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.HotWordGroupService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
|
|
@ -25,6 +27,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
|
||||
private final AiModelService aiModelService;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final HotWordGroupService hotWordGroupService;
|
||||
private final HotWordService hotWordService;
|
||||
private final AsrModelMapper asrModelMapper;
|
||||
private final LlmModelMapper llmModelMapper;
|
||||
|
|
@ -41,6 +44,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
Boolean enableItn,
|
||||
Boolean enableTextRefine,
|
||||
Boolean saveAudio,
|
||||
Long hotWordGroupId,
|
||||
List<String> hotWords) {
|
||||
long resolvedTenantId = tenantId == null ? 0L : tenantId;
|
||||
AiModelVO asrModel = resolveModel("ASR", asrModelId, resolvedTenantId);
|
||||
|
|
@ -54,7 +58,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
profile.setResolvedSummaryModelName(summaryModel.getModelName());
|
||||
profile.setResolvedPromptId(promptTemplate.getId());
|
||||
profile.setResolvedPromptName(promptTemplate.getTemplateName());
|
||||
profile.setResolvedHotWordGroupId(resolveHotWordGroupId(promptTemplate, hotWords));
|
||||
profile.setResolvedHotWordGroupId(resolveHotWordGroupId(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
|
||||
profile.setResolvedMode(nonBlank(mode, "2pass"));
|
||||
profile.setResolvedLanguage(nonBlank(language, "auto"));
|
||||
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
|
||||
|
|
@ -62,11 +66,18 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
|
||||
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
|
||||
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
|
||||
profile.setResolvedHotWords(resolveHotWords(promptTemplate, hotWords));
|
||||
profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
|
||||
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);
|
||||
if (!normalized.isEmpty()) {
|
||||
return null;
|
||||
|
|
@ -74,7 +85,20 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
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);
|
||||
if (!normalized.isEmpty()) {
|
||||
return normalized;
|
||||
|
|
@ -92,6 +116,15 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
|
|||
.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) {
|
||||
return hotWords == null ? List.of() : hotWords.stream()
|
||||
.filter(Objects::nonNull)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,26 @@ class MeetingAccessServiceImplTest {
|
|||
() -> 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) {
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setTenantId(100L);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
|
|||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.HotWordGroupVO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
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.LlmModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.HotWordGroupService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -30,10 +32,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
|
||||
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)
|
||||
|
|
@ -55,6 +59,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
null,
|
||||
Boolean.TRUE,
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
Arrays.asList(" alpha ", "", "alpha", "beta", null)
|
||||
);
|
||||
|
||||
|
|
@ -78,10 +83,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
void resolveShouldRejectCrossTenantModel() {
|
||||
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)
|
||||
|
|
@ -101,6 +108,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
|
@ -109,10 +117,12 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
|
||||
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)
|
||||
|
|
@ -142,6 +152,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
null,
|
||||
Boolean.FALSE,
|
||||
Boolean.FALSE,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
|
|
@ -153,12 +164,14 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||
HotWordService hotWordService = mock(HotWordService.class);
|
||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||
aiModelService,
|
||||
promptTemplateService,
|
||||
hotWordGroupService,
|
||||
hotWordService,
|
||||
asrModelMapper,
|
||||
llmModelMapper
|
||||
|
|
@ -184,6 +197,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
|
||||
|
|
@ -191,6 +205,55 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
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) {
|
||||
AiModelVO model = new AiModelVO();
|
||||
model.setId(id);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ export interface MeetingVO {
|
|||
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 {
|
||||
id?: number;
|
||||
title: string;
|
||||
|
|
@ -49,6 +66,7 @@ export interface CreateMeetingCommand {
|
|||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
promptId: number;
|
||||
hotWordGroupId?: number;
|
||||
userPrompt?: string;
|
||||
useSpkId?: number;
|
||||
enableTextRefine?: boolean;
|
||||
|
|
@ -67,6 +85,7 @@ export interface CreateRealtimeMeetingCommand {
|
|||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
promptId: number;
|
||||
hotWordGroupId?: number;
|
||||
userPrompt?: string;
|
||||
mode?: string;
|
||||
language?: string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
||||
import { getHotWordGroupOptions, HotWordGroupVO } from '../../api/business/hotwordGroup';
|
||||
import { listUsers, pageParams } from '../../api';
|
||||
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
|
||||
import { SysUser } from '../../types';
|
||||
|
|
@ -71,7 +72,9 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const [hotWordGroupTouched, setHotWordGroupTouched] = useState(false);
|
||||
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
|
@ -80,15 +83,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
|
||||
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
||||
const watchedPromptId = Form.useWatch("promptId", form);
|
||||
const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form);
|
||||
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
||||
|
||||
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 selectedPrompt = useMemo(() => prompts.find((item) => item.id === watchedPromptId) || null, [prompts, watchedPromptId]);
|
||||
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setType(initialType);
|
||||
setHotWordGroupTouched(false);
|
||||
loadInitialData();
|
||||
setAudioUrl('');
|
||||
setUploadProgress(0);
|
||||
|
|
@ -96,14 +102,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
}
|
||||
}, [open, initialType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || hotWordGroupTouched) {
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('hotWordGroupId', selectedPrompt?.hotWordGroupId);
|
||||
}, [form, hotWordGroupTouched, open, selectedPrompt]);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
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: 'LLM' }),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getHotWordPage({ current: 1, size: 1000 }),
|
||||
getHotWordGroupOptions(),
|
||||
listUsers(),
|
||||
getAiModelDefault("ASR"),
|
||||
getAiModelDefault("LLM"),
|
||||
|
|
@ -118,15 +132,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
setLlmModels(activeLlmModels);
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(activeHotwords);
|
||||
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
|
||||
setUserList(users || []);
|
||||
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
|
||||
const defaultPrompt = activePrompts.length > 0 ? activePrompts[0] : undefined;
|
||||
|
||||
form.setFieldsValue({
|
||||
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
||||
meetingTime: dayjs(),
|
||||
asrModelId: defaultAsr.data.data?.id,
|
||||
summaryModelId: defaultLlm.data.data?.id,
|
||||
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
||||
promptId: defaultPrompt?.id,
|
||||
hotWordGroupId: defaultPrompt?.hotWordGroupId,
|
||||
useSpkId: 1,
|
||||
enableTextRefine: false,
|
||||
mode: "2pass",
|
||||
|
|
@ -223,6 +240,12 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
setSubmitting(true);
|
||||
try {
|
||||
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') {
|
||||
await createMeeting({
|
||||
|
|
@ -231,13 +254,16 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
audioUrl,
|
||||
participants: meetingValues.participants?.join(','),
|
||||
tags: meetingValues.tags?.join(',')
|
||||
tags: meetingValues.tags?.join(','),
|
||||
hotWords: selectedHotWords
|
||||
});
|
||||
message.success('会议发起成功');
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
const selectedHotwords = hotwordList.map((item) => ({
|
||||
const selectedHotwords = hotwordList
|
||||
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
|
||||
.map((item) => ({
|
||||
hotword: item.word,
|
||||
weight: Number(item.weight || 2) / 10,
|
||||
}));
|
||||
|
|
@ -255,6 +281,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
enableItn: meetingValues.enableItn !== false,
|
||||
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||
saveAudio: !!meetingValues.saveAudio,
|
||||
hotWords: selectedHotWords,
|
||||
};
|
||||
|
||||
const res = await createRealtimeMeeting(payload);
|
||||
|
|
@ -426,6 +453,71 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
)}
|
||||
</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
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
|
|
@ -514,7 +606,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
<div>
|
||||
<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-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>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
MeetingTranscriptVO,
|
||||
MeetingVO,
|
||||
reSummary,
|
||||
resolveAudioMimeType,
|
||||
retryMeetingTranscription,
|
||||
updateMeetingBasic,
|
||||
updateMeetingTranscript,
|
||||
|
|
@ -655,6 +656,8 @@ const MeetingDetail: React.FC = () => {
|
|||
const [shareSaving, setShareSaving] = useState(false);
|
||||
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
||||
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
||||
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
|
||||
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
||||
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
|
@ -709,13 +712,26 @@ const MeetingDetail: React.FC = () => {
|
|||
const profileStr = sessionStorage.getItem('userProfile');
|
||||
if (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;
|
||||
}, [meeting]);
|
||||
|
||||
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
||||
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(() => {
|
||||
if (!meeting?.audioUrl) {
|
||||
|
|
@ -788,6 +804,21 @@ const MeetingDetail: React.FC = () => {
|
|||
}
|
||||
}, [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(() => {
|
||||
if (!sharePopoverOpen) {
|
||||
return;
|
||||
|
|
@ -797,39 +828,6 @@ const MeetingDetail: React.FC = () => {
|
|||
setSharePasswordDraft(normalizedPassword);
|
||||
}, [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 () => {
|
||||
try {
|
||||
const [modelRes, promptRes, defaultRes] = await Promise.all([
|
||||
|
|
@ -1078,6 +1076,62 @@ const MeetingDetail: React.FC = () => {
|
|||
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 = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
|
|
@ -1413,7 +1467,6 @@ const MeetingDetail: React.FC = () => {
|
|||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{meeting.status === 1 ? (
|
||||
<MeetingProgressDisplay
|
||||
|
|
@ -1427,8 +1480,9 @@ const MeetingDetail: React.FC = () => {
|
|||
/>
|
||||
) : (
|
||||
<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">
|
||||
{!emptyTranscriptFailureNotice && (
|
||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||
<div className="summary-head">
|
||||
<div className="summary-title">
|
||||
|
|
@ -1600,7 +1654,9 @@ const MeetingDetail: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!emptyTranscriptFailureNotice && (
|
||||
<div className="section-divider-note">
|
||||
<div className="section-divider-line" />
|
||||
<div className="section-divider-text">
|
||||
|
|
@ -1608,10 +1664,21 @@ const MeetingDetail: React.FC = () => {
|
|||
</div>
|
||||
<div className="section-divider-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
||||
<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' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
|
|
@ -1653,6 +1720,7 @@ const MeetingDetail: React.FC = () => {
|
|||
</div>
|
||||
</Col>
|
||||
|
||||
{!emptyTranscriptFailureNotice && (
|
||||
<Col xs={24} lg={10} style={{ height: '100%' }}>
|
||||
<div className="detail-side-column ai-summary-column">
|
||||
<Card
|
||||
|
|
@ -1716,6 +1784,7 @@ const MeetingDetail: React.FC = () => {
|
|||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1831,6 +1900,83 @@ const MeetingDetail: React.FC = () => {
|
|||
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));
|
||||
}
|
||||
.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 {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -2508,6 +2654,13 @@ const MeetingDetail: React.FC = () => {
|
|||
}
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.empty-transcript-hero__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
.empty-transcript-hero__actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meeting-share-card {
|
||||
width: min(86vw, 292px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import ReactMarkdown from "react-markdown";
|
|||
import {
|
||||
getMeetingPreviewAccess,
|
||||
getPublicMeetingPreview,
|
||||
resolveAudioMimeType,
|
||||
type MeetingTranscriptVO,
|
||||
type MeetingVO,
|
||||
} from "../../api/business/meeting";
|
||||
|
|
@ -163,6 +164,7 @@ export default function MeetingPreview() {
|
|||
const { id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||
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 safeTitle = title || TEXT.untitledMeeting;
|
||||
return safeTitle.split(/(\d+)/).map((part, index) =>
|
||||
|
|
@ -885,15 +903,17 @@ export default function MeetingPreview() {
|
|||
{meeting.audioUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={meeting.audioUrl}
|
||||
onTimeUpdate={handleAudioTimeUpdate}
|
||||
onPlay={handleAudioPlay}
|
||||
onPause={handleAudioPause}
|
||||
onEnded={handleAudioEnded}
|
||||
onLoadedMetadata={handleAudioLoadedMetadata}
|
||||
onError={handleAudioError}
|
||||
style={{ display: 'none' }}
|
||||
preload="metadata"
|
||||
/>
|
||||
preload="auto"
|
||||
>
|
||||
<source src={meeting.audioUrl} type={resolveAudioMimeType(meeting.audioUrl)} />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{meeting.audioUrl && pageTab === 'transcript' ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue