feat: 添加会议转录文件初始化和下载功能
- 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `initializeTranscriptFileIfAbsent` 方法调用 - 在 `MeetingController` 中添加 `exportTranscripts` 接口,支持下载会议转录 Markdown 文件 - 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持会议转录文件的下载 - 在相关测试类中添加对 `MeetingTranscriptFileService` 的 mockdev_na
parent
aaa2624fe2
commit
35698287de
|
|
@ -7,6 +7,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
||||||
import com.imeeting.dto.biz.MeetingResummaryDTO;
|
import com.imeeting.dto.biz.MeetingResummaryDTO;
|
||||||
import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
|
import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
|
||||||
import com.imeeting.dto.biz.MeetingSummaryExportResult;
|
import com.imeeting.dto.biz.MeetingSummaryExportResult;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptExportResult;
|
||||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand;
|
import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand;
|
||||||
|
|
@ -23,6 +24,7 @@ import com.imeeting.service.biz.MeetingAccessService;
|
||||||
import com.imeeting.service.biz.MeetingCommandService;
|
import com.imeeting.service.biz.MeetingCommandService;
|
||||||
import com.imeeting.service.biz.MeetingExportService;
|
import com.imeeting.service.biz.MeetingExportService;
|
||||||
import com.imeeting.service.biz.MeetingQueryService;
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||||
|
|
@ -67,6 +69,7 @@ public class MeetingController {
|
||||||
private final MeetingCommandService meetingCommandService;
|
private final MeetingCommandService meetingCommandService;
|
||||||
private final MeetingAccessService meetingAccessService;
|
private final MeetingAccessService meetingAccessService;
|
||||||
private final MeetingExportService meetingExportService;
|
private final MeetingExportService meetingExportService;
|
||||||
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
|
@ -77,6 +80,7 @@ public class MeetingController {
|
||||||
MeetingCommandService meetingCommandService,
|
MeetingCommandService meetingCommandService,
|
||||||
MeetingAccessService meetingAccessService,
|
MeetingAccessService meetingAccessService,
|
||||||
MeetingExportService meetingExportService,
|
MeetingExportService meetingExportService,
|
||||||
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
PromptTemplateService promptTemplateService,
|
PromptTemplateService promptTemplateService,
|
||||||
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
|
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
|
||||||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||||
|
|
@ -86,6 +90,7 @@ public class MeetingController {
|
||||||
this.meetingCommandService = meetingCommandService;
|
this.meetingCommandService = meetingCommandService;
|
||||||
this.meetingAccessService = meetingAccessService;
|
this.meetingAccessService = meetingAccessService;
|
||||||
this.meetingExportService = meetingExportService;
|
this.meetingExportService = meetingExportService;
|
||||||
|
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||||
this.promptTemplateService = promptTemplateService;
|
this.promptTemplateService = promptTemplateService;
|
||||||
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
|
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
|
||||||
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
||||||
|
|
@ -227,6 +232,27 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "下载会议转录 Markdown")
|
||||||
|
@GetMapping("/{id}/transcripts/export")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<byte[]> exportTranscripts(@PathVariable Long id) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanExportMeeting(meeting, loginUser);
|
||||||
|
|
||||||
|
MeetingVO meetingDetail = meetingQueryService.getDetail(id);
|
||||||
|
if (meetingDetail == null) {
|
||||||
|
throw new RuntimeException("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
MeetingTranscriptExportResult exportResult = meetingTranscriptFileService.exportTranscript(meeting, meetingDetail);
|
||||||
|
String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
|
||||||
|
.contentType(MediaType.parseMediaType(exportResult.getContentType()))
|
||||||
|
.body(exportResult.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询实时会议状态")
|
@Operation(summary = "查询实时会议状态")
|
||||||
@GetMapping("/{id}/realtime/session-status")
|
@GetMapping("/{id}/realtime/session-status")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
|
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
|
@ -58,6 +59,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final HotWordService hotWordService;
|
private final HotWordService hotWordService;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
|
|
@ -310,6 +312,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
JsonNode segments = resultNode.path("segments");
|
JsonNode segments = resultNode.path("segments");
|
||||||
|
int savedCount = 0;
|
||||||
if (segments.isArray()) {
|
if (segments.isArray()) {
|
||||||
int order = 0;
|
int order = 0;
|
||||||
for (JsonNode seg : segments) {
|
for (JsonNode seg : segments) {
|
||||||
|
|
@ -325,9 +328,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
fillTranscriptTime(mt, seg);
|
fillTranscriptTime(mt, seg);
|
||||||
mt.setSortOrder(order++);
|
mt.setSortOrder(order++);
|
||||||
transcriptMapper.insert(mt);
|
transcriptMapper.insert(mt);
|
||||||
|
savedCount++;
|
||||||
sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n");
|
sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (savedCount > 0) {
|
||||||
|
meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meeting.getId());
|
||||||
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import com.imeeting.service.biz.MeetingCommandService;
|
||||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -53,6 +54,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final HotWordService hotWordService;
|
private final HotWordService hotWordService;
|
||||||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
|
@ -194,6 +196,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inserted) {
|
if (inserted) {
|
||||||
|
meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId);
|
||||||
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +225,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
.set(MeetingTranscript::getContent, content)
|
.set(MeetingTranscript::getContent, content)
|
||||||
.set(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime())
|
.set(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime())
|
||||||
.set(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime()));
|
.set(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime()));
|
||||||
|
meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId);
|
||||||
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +248,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
transcript.setEndTime(item.getEndTime());
|
transcript.setEndTime(item.getEndTime());
|
||||||
transcript.setSortOrder(maxSortOrder == null ? 0 : maxSortOrder + 1);
|
transcript.setSortOrder(maxSortOrder == null ? 0 : maxSortOrder + 1);
|
||||||
transcriptMapper.insert(transcript);
|
transcriptMapper.insert(transcript);
|
||||||
|
meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId);
|
||||||
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -21,9 +22,11 @@ import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
|
@ -90,7 +93,8 @@ class AiTaskServiceImplTest {
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
aiModelService,
|
aiModelService,
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner()
|
new TaskSecurityContextRunner(),
|
||||||
|
mock(MeetingTranscriptFileService.class)
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -133,7 +137,8 @@ class AiTaskServiceImplTest {
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
aiModelService,
|
aiModelService,
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner()
|
new TaskSecurityContextRunner(),
|
||||||
|
mock(MeetingTranscriptFileService.class)
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -160,13 +165,53 @@ class AiTaskServiceImplTest {
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
|
||||||
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
|
MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class);
|
||||||
|
AiTaskServiceImpl service = createService(
|
||||||
|
meetingMapper,
|
||||||
|
transcriptMapper,
|
||||||
|
mock(AiModelService.class),
|
||||||
|
mock(StringRedisTemplate.class),
|
||||||
|
mock(TaskSecurityContextRunner.class),
|
||||||
|
transcriptFileService
|
||||||
|
);
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(88L);
|
||||||
|
|
||||||
|
ReflectionTestUtils.invokeMethod(
|
||||||
|
service,
|
||||||
|
"saveTranscripts",
|
||||||
|
meeting,
|
||||||
|
new ObjectMapper().readTree("""
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"speaker_id": "spk-1",
|
||||||
|
"speaker_name": "Alice",
|
||||||
|
"text": "第一段转录",
|
||||||
|
"timestamp": [0, 1200]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(transcriptMapper, times(1)).insert(any(MeetingTranscript.class));
|
||||||
|
verify(transcriptFileService, times(1)).initializeTranscriptFileIfAbsent(eq(88L));
|
||||||
|
}
|
||||||
|
|
||||||
private AiTaskServiceImpl createService() {
|
private AiTaskServiceImpl createService() {
|
||||||
return createService(
|
return createService(
|
||||||
mock(MeetingMapper.class),
|
mock(MeetingMapper.class),
|
||||||
mock(MeetingTranscriptMapper.class),
|
mock(MeetingTranscriptMapper.class),
|
||||||
mock(AiModelService.class),
|
mock(AiModelService.class),
|
||||||
mock(StringRedisTemplate.class),
|
mock(StringRedisTemplate.class),
|
||||||
mock(TaskSecurityContextRunner.class)
|
mock(TaskSecurityContextRunner.class),
|
||||||
|
mock(MeetingTranscriptFileService.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +219,8 @@ class AiTaskServiceImplTest {
|
||||||
MeetingTranscriptMapper transcriptMapper,
|
MeetingTranscriptMapper transcriptMapper,
|
||||||
AiModelService aiModelService,
|
AiModelService aiModelService,
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner) {
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
|
MeetingTranscriptFileService meetingTranscriptFileService) {
|
||||||
return new AiTaskServiceImpl(
|
return new AiTaskServiceImpl(
|
||||||
meetingMapper,
|
meetingMapper,
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
|
|
@ -184,6 +230,7 @@ class AiTaskServiceImplTest {
|
||||||
mock(HotWordService.class),
|
mock(HotWordService.class),
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
mock(MeetingSummaryFileService.class),
|
mock(MeetingSummaryFileService.class),
|
||||||
|
meetingTranscriptFileService,
|
||||||
mock(MeetingSummaryPromptAssembler.class),
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
taskSecurityContextRunner
|
taskSecurityContextRunner
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -398,3 +398,11 @@ export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const downloadMeetingTranscript = (id: number) => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
return axios.get(`/api/biz/meeting/${id}/transcripts/export`, {
|
||||||
|
responseType: "blob",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?:
|
||||||
function getAvatarUrl(profile?: CachedUserProfile | null) {
|
function getAvatarUrl(profile?: CachedUserProfile | null) {
|
||||||
return profile?.avatarUrl?.trim() || "";
|
return profile?.avatarUrl?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDisplayName(profile: CachedUserProfile | null | undefined, fallbackLabel: string) {
|
||||||
|
return profile?.displayName || profile?.username || localStorage.getItem("displayName") || localStorage.getItem("username") || fallbackLabel;
|
||||||
|
}
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -79,19 +83,19 @@ export default function AppLayout() {
|
||||||
const { load: loadPermissions, can } = usePermission();
|
const { load: loadPermissions, can } = usePermission();
|
||||||
const { layoutMode } = useThemeStore();
|
const { layoutMode } = useThemeStore();
|
||||||
|
|
||||||
const currentUserDisplayName = useMemo(() => {
|
const [currentUserDisplayName, setCurrentUserDisplayName] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (profileStr) {
|
if (profileStr) {
|
||||||
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
||||||
return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
return getDisplayName(profile, t("layout.admin"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore invalid cached profile and continue with storage fallback.
|
// Ignore invalid cached profile and continue with storage fallback.
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
return getDisplayName(null, t("layout.admin"));
|
||||||
}, [t]);
|
});
|
||||||
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
|
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
|
||||||
try {
|
try {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
|
@ -131,16 +135,26 @@ export default function AppLayout() {
|
||||||
const syncUserProfile = () => {
|
const syncUserProfile = () => {
|
||||||
try {
|
try {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (!profileStr) return;
|
if (!profileStr) {
|
||||||
|
setCurrentUserDisplayName(getDisplayName(null, t("layout.admin")));
|
||||||
|
setCurrentUserAvatarUrl("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
||||||
|
localStorage.setItem("displayName", profile.displayName || profile.username || "");
|
||||||
|
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
|
||||||
|
setCurrentUserDisplayName(getDisplayName(profile, t("layout.admin")));
|
||||||
setCurrentUserAvatarUrl(getAvatarUrl(profile));
|
setCurrentUserAvatarUrl(getAvatarUrl(profile));
|
||||||
} catch {
|
} catch {
|
||||||
|
setCurrentUserDisplayName(getDisplayName(null, t("layout.admin")));
|
||||||
|
setCurrentUserAvatarUrl("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
syncUserProfile();
|
||||||
window.addEventListener("user-profile-updated", syncUserProfile);
|
window.addEventListener("user-profile-updated", syncUserProfile);
|
||||||
return () => window.removeEventListener("user-profile-updated", syncUserProfile);
|
return () => window.removeEventListener("user-profile-updated", syncUserProfile);
|
||||||
}, []);
|
}, [t]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncPlatformConfig = () => {
|
const syncPlatformConfig = () => {
|
||||||
const configStr = sessionStorage.getItem("platformConfig");
|
const configStr = sessionStorage.getItem("platformConfig");
|
||||||
|
|
@ -165,6 +179,7 @@ export default function AppLayout() {
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
localStorage.setItem("displayName", profile.displayName || profile.username || "");
|
localStorage.setItem("displayName", profile.displayName || profile.username || "");
|
||||||
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
|
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
|
||||||
|
window.dispatchEvent(new Event("user-profile-updated"));
|
||||||
|
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,11 @@ export default function Login() {
|
||||||
try {
|
try {
|
||||||
const profile = await getCurrentUser();
|
const profile = await getCurrentUser();
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
localStorage.setItem("displayName", profile.displayName || profile.username || "");
|
||||||
|
localStorage.setItem("username", profile.username || values.username);
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.removeItem("userProfile");
|
sessionStorage.removeItem("userProfile");
|
||||||
|
localStorage.removeItem("displayName");
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,10 @@ const HotWords: React.FC = () => {
|
||||||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="pinyin" hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="category" label="热词分类">
|
<Form.Item name="category" label="热词分类">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import {
|
import {
|
||||||
|
downloadMeetingTranscript,
|
||||||
downloadMeetingSummary,
|
downloadMeetingSummary,
|
||||||
getMeetingDetail,
|
getMeetingDetail,
|
||||||
getMeetingProgress,
|
getMeetingProgress,
|
||||||
|
|
@ -651,7 +652,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [editVisible, setEditVisible] = useState(false);
|
const [editVisible, setEditVisible] = useState(false);
|
||||||
const [summaryVisible, setSummaryVisible] = useState(false);
|
const [summaryVisible, setSummaryVisible] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null);
|
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | 'transcript' | null>(null);
|
||||||
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
||||||
const [summaryDraft, setSummaryDraft] = useState('');
|
const [summaryDraft, setSummaryDraft] = useState('');
|
||||||
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
|
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
|
||||||
|
|
@ -1236,6 +1237,49 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadTranscript = async () => {
|
||||||
|
if (!meeting) return;
|
||||||
|
if (transcripts.length === 0) {
|
||||||
|
message.warning('当前暂无可下载的会议转录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDownloadLoading('transcript');
|
||||||
|
const res = await downloadMeetingTranscript(meeting.id);
|
||||||
|
const contentType = res.headers['content-type'] || 'text/markdown; charset=UTF-8';
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const text = await (res.data as Blob).text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
message.error(json?.msg || '下载失败');
|
||||||
|
} catch {
|
||||||
|
message.error('下载失败');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([res.data], { type: contentType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = getFileNameFromDisposition(
|
||||||
|
res.headers['content-disposition'],
|
||||||
|
`${sanitizeDownloadFileName(meeting.title, 'meeting-transcript')}-Transcript.md`,
|
||||||
|
);
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error('转录下载失败');
|
||||||
|
} finally {
|
||||||
|
setDownloadLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadAudio = () => {
|
const handleDownloadAudio = () => {
|
||||||
if (!playbackAudioUrl) {
|
if (!playbackAudioUrl) {
|
||||||
message.warning('当前暂无可下载的会议录音');
|
message.warning('当前暂无可下载的会议录音');
|
||||||
|
|
@ -1460,7 +1504,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
正在总结
|
正在总结
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(playbackAudioUrl || (meeting.status === 3 && !!meeting.summaryContent)) && (
|
{(playbackAudioUrl || transcripts.length > 0 || (meeting.status === 3 && !!meeting.summaryContent)) && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -1470,7 +1514,19 @@ const MeetingDetail: React.FC = () => {
|
||||||
icon: <AudioOutlined />,
|
icon: <AudioOutlined />,
|
||||||
onClick: handleDownloadAudio,
|
onClick: handleDownloadAudio,
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(transcripts.length > 0 && !(meeting.status === 3 && !!meeting.summaryContent) ? [{
|
||||||
|
key: 'transcript',
|
||||||
|
label: '下载转录 MD',
|
||||||
|
icon: <DownloadOutlined />,
|
||||||
|
onClick: handleDownloadTranscript,
|
||||||
|
disabled: downloadLoading === 'transcript'
|
||||||
|
}] : []),
|
||||||
...(meeting.status === 3 && !!meeting.summaryContent ? [
|
...(meeting.status === 3 && !!meeting.summaryContent ? [
|
||||||
|
{
|
||||||
|
key: 'transcript',
|
||||||
|
label: '下载转录 MD',
|
||||||
|
icon: <DownloadOutlined />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'pdf',
|
key: 'pdf',
|
||||||
label: '下载 PDF',
|
label: '下载 PDF',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export default function Profile() {
|
||||||
const data = await getCurrentUser();
|
const data = await getCurrentUser();
|
||||||
setUser(data);
|
setUser(data);
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(data));
|
sessionStorage.setItem("userProfile", JSON.stringify(data));
|
||||||
|
localStorage.setItem("displayName", data.displayName || data.username || "");
|
||||||
|
localStorage.setItem("username", data.username || localStorage.getItem("username") || "");
|
||||||
window.dispatchEvent(new Event("user-profile-updated"));
|
window.dispatchEvent(new Event("user-profile-updated"));
|
||||||
profileForm.setFieldsValue(data);
|
profileForm.setFieldsValue(data);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue