feat: 添加会议转录文件初始化和下载功能

- 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `initializeTranscriptFileIfAbsent` 方法调用
- 在 `MeetingController` 中添加 `exportTranscripts` 接口,支持下载会议转录 Markdown 文件
- 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持会议转录文件的下载
- 在相关测试类中添加对 `MeetingTranscriptFileService` 的 mock
dev_na
chenhao 2026-04-28 10:34:15 +08:00
parent aaa2624fe2
commit 35698287de
10 changed files with 185 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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="热词分类">

View File

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

View File

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