feat: 添加 M4A 文件验证和音频播放错误处理

- 在 `MeetingAudioUploadSupport` 中添加 M4A 文件验证逻辑,确保文件可播放
- 更新前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 以处理音频播放错误,并显示相应的警告信息
- 在 `WebMvcConfig` 中配置 M4A 媒
dev_na
chenhao 2026-04-27 10:39:34 +08:00
parent 5aefcf8d7d
commit 6600d37757
15 changed files with 598 additions and 54 deletions

View File

@ -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) {
// 确保目录存在 // 确保目录存在

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -137,6 +137,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
null, null,
null, null,
null, null,
null,
List.of() List.of()
); );

View File

@ -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);
} }

View File

@ -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 + ",浏览器通常只支持 AACmp4a编码请转为 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) {
}
} }

View File

@ -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()
); );
} }

View File

@ -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)

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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' }}>

View File

@ -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);
} }

View File

@ -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' ? (