From 12c79cdf260ce7113986ac39913e9dcb64278c57 Mon Sep 17 00:00:00 2001 From: chenhao Date: Fri, 27 Mar 2026 10:30:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84MeetingDetail?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AE=AE?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=92=8C=E6=99=BA=E8=83=BD=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 `MeetingDetail` 组件,优化会议详情和智能分析的展示 - 增加关键词、全文概要、章节速览、发言总结、要点回顾和待办事项的展示逻辑 - 优化音频播放器和进度条功能 - 更新表单验证和数据处理逻辑 - 修复部分样式和布局问题 --- .../java/com/imeeting/dto/biz/MeetingVO.java | 2 + .../biz/MeetingSummaryFileService.java | 9 + .../service/biz/impl/AiTaskServiceImpl.java | 238 ++- .../biz/impl/MeetingDomainSupport.java | 1 + .../impl/MeetingSummaryFileServiceImpl.java | 332 +++- frontend/src/api/business/meeting.ts | 8 + frontend/src/pages/business/MeetingDetail.tsx | 1438 ++++++++++++++--- frontend/src/pages/home/RightVisual.less | 154 ++ frontend/src/pages/home/RightVisual.tsx | 58 + 9 files changed, 1976 insertions(+), 264 deletions(-) create mode 100644 frontend/src/pages/home/RightVisual.less create mode 100644 frontend/src/pages/home/RightVisual.tsx diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index 64335c8..0af0993 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Data public class MeetingVO { @@ -22,6 +23,7 @@ public class MeetingVO { private String audioUrl; private Integer duration; private String summaryContent; + private Map analysis; private Integer status; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java index 62c820c..0c5ac4a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java @@ -3,12 +3,21 @@ package com.imeeting.service.biz; import com.imeeting.entity.biz.Meeting; import java.nio.file.Path; +import java.util.Map; public interface MeetingSummaryFileService { Path requireSummarySourcePath(Meeting meeting); String loadSummaryContent(Meeting meeting); + Map loadSummaryAnalysis(Meeting meeting); + + Map parseSummaryBundle(String rawContent); + + Map parseSummaryAnalysis(String rawContent); + + String buildSummaryMarkdown(Map analysis); + void updateSummaryContent(Meeting meeting, String summaryContent); String stripFrontMatter(String markdown); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 259d981..76f676a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -17,6 +17,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.HotWordService; +import com.imeeting.service.biz.MeetingSummaryFileService; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; @@ -55,6 +56,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final SysUserMapper sysUserMapper; private final HotWordService hotWordService; private final StringRedisTemplate redisTemplate; + private final MeetingSummaryFileService meetingSummaryFileService; @Value("${unisbase.app.server-base-url}") private String serverBaseUrl; @@ -197,7 +199,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme String status = data.path("status").asText(); if ("completed".equalsIgnoreCase(status)) { - resultNode = data.path("result"); + resultNode = extractAsrResultNode(data); updateAiTaskSuccess(taskRecord, statusNode); break; } else if ("failed".equalsIgnoreCase(status)) { @@ -205,7 +207,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme throw new RuntimeException("ASR引擎处理失败: " + data.path("message").asText()); } else { int currentPercent = data.path("percentage").asInt(); - int eta = data.path("eta").asInt(0); + int eta = data.path("eta_seconds").asInt(statusNode.path("eta_seconds").asInt(data.path("eta").asInt(0))); updateProgress(meeting.getId(), (int)(currentPercent * 0.85), data.path("message").asText(), eta); if (currentPercent > 0 && currentPercent == lastPercent) { @@ -276,22 +278,13 @@ public class AiTaskServiceImpl extends ServiceImpl impleme MeetingTranscript mt = new MeetingTranscript(); mt.setMeetingId(meeting.getId()); - JsonNode spkNode = seg.path("speaker"); - String spkId = spkNode.path("user_id").asText("spk_0"); - String spkName = spkNode.path("name").asText(spkId); + String spkId = extractSpeakerId(seg); + String spkName = resolveTranscriptSpeakerName(seg, spkId); - if (spkId.matches("\\d+")) { - SysUser user = sysUserMapper.selectById(Long.parseLong(spkId)); - if (user != null) spkName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); - } - mt.setSpeakerId(spkId); mt.setSpeakerName(spkName); - mt.setContent(seg.path("text").asText()); - if (seg.has("timestamp")) { - mt.setStartTime(seg.path("timestamp").path(0).asInt()); - mt.setEndTime(seg.path("timestamp").path(1).asInt()); - } + mt.setContent(seg.path("text").asText("")); + fillTranscriptTime(mt, seg); mt.setSortOrder(order++); transcriptMapper.insert(mt); sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n"); @@ -300,6 +293,100 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return sb.toString(); } + private JsonNode extractAsrResultNode(JsonNode data) { + JsonNode resultNode = data.path("result"); + if (!resultNode.isMissingNode() && !resultNode.isNull()) { + return resultNode; + } + return data; + } + + private String extractSpeakerId(JsonNode seg) { + String speakerId = seg.path("speaker_id").asText(""); + if (speakerId == null || speakerId.isBlank()) { + JsonNode speakerNode = seg.path("speaker"); + speakerId = speakerNode.path("user_id").asText(""); + if (speakerId == null || speakerId.isBlank()) { + speakerId = speakerNode.path("id").asText(""); + } + } + if (speakerId == null || speakerId.isBlank()) { + speakerId = seg.path("user_id").asText(""); + } + if (speakerId == null || speakerId.isBlank()) { + return "spk_0"; + } + return speakerId.trim(); + } + + private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId) { + String speakerName = seg.path("speaker_name").asText(""); + if (speakerName == null || speakerName.isBlank()) { + JsonNode speakerNode = seg.path("speaker"); + speakerName = speakerNode.path("name").asText(""); + if (speakerName == null || speakerName.isBlank()) { + speakerName = speakerNode.path("speaker_name").asText(""); + } + } + + String userId = seg.path("user_id").asText(""); + if (userId == null || userId.isBlank()) { + userId = seg.path("speaker").path("user_id").asText(""); + } + String resolvedUserName = resolveUserName(userId); + if (resolvedUserName != null) { + return resolvedUserName; + } + + if (speakerId != null && speakerId.matches("\\d+")) { + String resolvedSpeakerName = resolveUserName(speakerId); + if (resolvedSpeakerName != null) { + return resolvedSpeakerName; + } + } + + if (speakerName == null || speakerName.isBlank()) { + return speakerId; + } + return speakerName.trim(); + } + + private String resolveUserName(String userId) { + if (userId == null || userId.isBlank() || !userId.matches("\\d+")) { + return null; + } + SysUser user = sysUserMapper.selectById(Long.parseLong(userId)); + if (user == null) { + return null; + } + return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); + } + + private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) { + JsonNode timestamp = seg.path("timestamp"); + if (timestamp.isArray() && timestamp.size() >= 2) { + transcript.setStartTime(timestamp.path(0).asInt()); + transcript.setEndTime(timestamp.path(1).asInt()); + return; + } + + Integer startTime = readSecondsAsMillis(seg.get("start")); + Integer endTime = readSecondsAsMillis(seg.get("end")); + if (startTime != null) { + transcript.setStartTime(startTime); + } + if (endTime != null) { + transcript.setEndTime(endTime); + } + } + + private Integer readSecondsAsMillis(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull() || !node.isNumber()) { + return null; + } + return (int) Math.round(node.asDouble() * 1000D); + } + private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception { updateMeetingStatus(meeting.getId(), 2); updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0); @@ -319,8 +406,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme req.put("model", llmModel.getModelCode()); req.put("temperature", llmModel.getTemperature()); req.put("messages", List.of( - Map.of("role", "system", "content", promptContent), - Map.of("role", "user", "content", "请总结以下会议内容:\n" + asrText) + Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)), + Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText)) )); taskRecord.setRequestData(req); @@ -344,6 +431,17 @@ public class AiTaskServiceImpl extends ServiceImpl impleme if (response.statusCode() == 200 && respNode.has("choices")) { String content = sanitizeSummaryContent(respNode.path("choices").path(0).path("message").path("content").asText()); + Map summaryBundle = meetingSummaryFileService.parseSummaryBundle(content); + String markdownContent = summaryBundle != null + ? String.valueOf(summaryBundle.getOrDefault("summaryContent", "")) + : content; + if (markdownContent == null || markdownContent.isBlank()) { + markdownContent = content; + } + @SuppressWarnings("unchecked") + Map normalizedAnalysis = summaryBundle != null + ? (Map) summaryBundle.get("analysis") + : meetingSummaryFileService.parseSummaryAnalysis(content); // Save to File String timestamp = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); @@ -352,15 +450,25 @@ public class AiTaskServiceImpl extends ServiceImpl impleme Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meeting.getId()), "summaries"); Files.createDirectories(targetDir); Path filePath = targetDir.resolve(fileName); - - String frontMatter = "---\n" + - "生成时间: " + LocalDateTime.now() + "\n" + - "使用模型: " + llmModel.getModelName() + "\n" + - "---\n\n"; - Files.writeString(filePath, frontMatter + content, StandardCharsets.UTF_8); + + Files.writeString(filePath, markdownContent, StandardCharsets.UTF_8); taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName); - updateAiTaskSuccess(taskRecord, respNode); + if (summaryBundle != null || normalizedAnalysis != null) { + Map responseData = objectMapper.convertValue(respNode, Map.class); + if (summaryBundle != null) { + responseData.put("summaryBundle", summaryBundle); + } + if (normalizedAnalysis != null) { + responseData.put("normalizedAnalysis", normalizedAnalysis); + } + taskRecord.setResponseData(responseData); + taskRecord.setStatus(2); + taskRecord.setCompletedAt(LocalDateTime.now()); + this.updateById(taskRecord); + } else { + updateAiTaskSuccess(taskRecord, respNode); + } meeting.setLatestSummaryTaskId(taskRecord.getId()); meeting.setStatus(3); @@ -442,6 +550,88 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return normalized.substring(firstLineEnd + 1, lastFence).trim(); } + private String buildSummarySystemPrompt(String promptContent) { + String basePrompt = (promptContent == null || promptContent.isBlank()) + ? "你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。" + : promptContent.trim(); + return String.join("\n\n", + "你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。", + "对于 summaryContent,你必须严格遵循以下会议总结模板/风格要求,不要被后续结构化字段说明覆盖:\n" + basePrompt, + "如果模板中已经定义了标题层级、章节顺序、栏目名称、语气、措辞风格、段落组织方式,你必须保持一致。", + "summaryContent 不允许退化成关键词罗列、JSON 翻译或结构化字段拼接,必须是一篇可直接阅读、可直接导出的正式会议纪要正文。", + "analysis 是附加产物,只服务于页面结构化展示;analysis 不能改变 summaryContent 的模板风格和写法。", + "你需要一次性返回一个 JSON 对象,其中同时包含原会议纪要正文和结构化 analysis 结果。", + "最终只返回 JSON,不要输出 markdown 代码围栏、解释、前后缀或额外说明。" + ); + } + + private String buildSummaryUserPrompt(Meeting meeting, String asrText) { + String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank() + ? "未填写" + : meeting.getParticipants(); + Integer durationMs = resolveMeetingDuration(meeting.getId()); + String durationText = durationMs == null || durationMs <= 0 ? "未知" : formatDuration(durationMs); + String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString(); + + return String.join("\n", + "请基于以下会议转写,一次性生成会议纪要正文和结构化分析结果。", + "会议基础信息:", + "标题:" + (meeting.getTitle() == null || meeting.getTitle().isBlank() ? "未命名会议" : meeting.getTitle()), + "会议时间:" + meetingTime, + "参会人员:" + participants, + "会议时长:" + durationText, + "返回 JSON,字段结构固定如下:", + "{", + " \"summaryContent\": \"原会议纪要正文,使用 markdown,保持自然完整的纪要写法,而不是关键词列表拼接\",", + " \"analysis\": {", + " \"overview\": \"基于整场会议内容生成的中文全文概要,控制在300字内,需尽量覆盖完整讨论内容\",", + " \"keywords\": [\"关键词1\", \"关键词2\"],", + " \"chapters\": [{\"time\":\"00:00\",\"title\":\"章节标题\",\"summary\":\"章节摘要\"}],", + " \"speakerSummaries\": [{\"speaker\":\"发言人 1\",\"summary\":\"该发言人在整场会议中的主要观点总结\"}],", + " \"keyPoints\": [{\"title\":\"重点问题或结论\",\"summary\":\"具体说明\",\"speaker\":\"发言人 1\",\"time\":\"00:00\"}],", + " \"todos\": [\"待办事项1\", \"待办事项2\"]", + " }", + "}", + "要求:", + "1. summaryContent 必须是完整会议纪要正文,优先遵循提示词模板中的标题层级、章节顺序、栏目名称、措辞风格和段落组织方式。", + "2. 如果模板里已经定义了固定标题或固定分节顺序,summaryContent 必须严格复用,不要自行改写成别的结构。", + "3. summaryContent 必须保持自然完整、适合阅读和导出,不能写成关键词清单,也不能直接把 analysis 内容原样展开。", + "4. analysis 是给页面结构化展示使用的单独结果。", + "5. analysis.overview 必须基于所有会话内容,控制在 300 字内。", + "6. analysis.speakerSummaries 必须按发言人聚合,每个发言人只出现一次。", + "7. analysis.chapters 按时间顺序输出,time 使用 mm:ss 或 hh:mm:ss。", + "8. analysis.keyPoints 提炼关键问题、决定、结论或争议点。", + "9. analysis.todos 尽量写成可执行动作;没有就返回空数组。", + "10. 只返回 JSON。", + "", + "会议转写如下:", + asrText + ); + } + + private Integer resolveMeetingDuration(Long meetingId) { + MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .isNotNull(MeetingTranscript::getEndTime) + .orderByDesc(MeetingTranscript::getEndTime) + .last("LIMIT 1")); + if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) { + return latestTranscript.getEndTime(); + } + return null; + } + + private String formatDuration(int durationMs) { + int totalSeconds = Math.max(durationMs / 1000, 0); + int hours = totalSeconds / 3600; + int minutes = (totalSeconds % 3600) / 60; + int seconds = totalSeconds % 60; + if (hours > 0) { + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } + return String.format("%02d:%02d", minutes, seconds); + } + private void updateMeetingStatus(Long id, int status) { Meeting m = new Meeting(); m.setId(id); m.setStatus(status); meetingMapper.updateById(m); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index e8b380a..b97b86b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -193,6 +193,7 @@ public class MeetingDomainSupport { } if (includeSummary) { vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting)); + vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting)); } } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java index 36375a1..ee8f78a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java @@ -1,9 +1,11 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; -import com.imeeting.service.biz.AiTaskService; +import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.service.biz.MeetingSummaryFileService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -14,12 +16,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService { - private final AiTaskService aiTaskService; + private final AiTaskMapper aiTaskMapper; + private final ObjectMapper objectMapper; @Value("${unisbase.app.upload-path}") private String uploadPath; @@ -52,6 +60,163 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService } } + @Override + public Map loadSummaryAnalysis(Meeting meeting) { + try { + AiTask summaryTask = findLatestSummaryTask(meeting); + if (summaryTask == null) { + return null; + } + + Object bundle = summaryTask.getResponseData() != null ? summaryTask.getResponseData().get("summaryBundle") : null; + if (bundle instanceof Map bundleMap) { + Object analysis = bundleMap.get("analysis"); + if (analysis instanceof Map analysisMap) { + return objectMapper.convertValue(analysisMap, new TypeReference>() {}); + } + } + + Object normalized = summaryTask.getResponseData() != null ? summaryTask.getResponseData().get("normalizedAnalysis") : null; + if (normalized instanceof Map map) { + return objectMapper.convertValue(map, new TypeReference>() {}); + } + + Object content = extractSummaryContent(summaryTask); + if (content instanceof String text) { + Map parsedBundle = parseSummaryBundle(text); + if (parsedBundle != null && parsedBundle.get("analysis") instanceof Map analysisMap) { + return objectMapper.convertValue(analysisMap, new TypeReference>() {}); + } + return parseSummaryAnalysis(text); + } + return null; + } catch (Exception ex) { + throw new RuntimeException("Load summary analysis failed", ex); + } + } + + @Override + public Map parseSummaryBundle(String rawContent) { + if (rawContent == null || rawContent.isBlank()) { + return null; + } + Map parsed = tryParseJson(rawContent.trim()); + if (parsed == null) { + return null; + } + + boolean hasSummary = parsed.containsKey("summaryContent"); + boolean hasAnalysis = parsed.containsKey("analysis"); + if (!hasSummary && !hasAnalysis) { + return null; + } + + Map bundle = new LinkedHashMap<>(); + bundle.put("summaryContent", normalizeSummaryMarkdown(asText(parsed.get("summaryContent")))); + + Object analysisValue = parsed.get("analysis"); + if (analysisValue instanceof Map analysisMap) { + Map analysis = objectMapper.convertValue(analysisMap, new TypeReference>() {}); + bundle.put("analysis", parseSummaryAnalysisFromMap(analysis)); + } else { + bundle.put("analysis", null); + } + return bundle; + } + + @Override + public Map parseSummaryAnalysis(String rawContent) { + if (rawContent == null || rawContent.isBlank()) { + return null; + } + Map parsed = tryParseJson(rawContent.trim()); + if (parsed == null) { + return null; + } + + if (parsed.get("analysis") instanceof Map analysis) { + parsed = objectMapper.convertValue(analysis, new TypeReference>() {}); + } + + return parseSummaryAnalysisFromMap(parsed); + } + + private Map parseSummaryAnalysisFromMap(Map parsed) { + Map normalized = new LinkedHashMap<>(); + normalized.put("overview", clipText(asText(parsed.get("overview")), 500)); + normalized.put("keywords", normalizeStringList(parsed.get("keywords"))); + normalized.put("chapters", normalizeChapterList(parsed.get("chapters"))); + normalized.put("speakerSummaries", normalizeSpeakerSummaries(parsed.get("speakerSummaries"))); + normalized.put("keyPoints", normalizeKeyPoints(parsed.get("keyPoints"))); + List todos = normalizeStringList(parsed.containsKey("todos") ? parsed.get("todos") : parsed.get("actionItems")); + normalized.put("todos", todos); + return normalized; + } + + @Override + public String buildSummaryMarkdown(Map analysis) { + if (analysis == null || analysis.isEmpty()) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + appendSection(builder, "全文概要", List.of(asText(analysis.get("overview")))); + appendSection(builder, "关键词", normalizeStringList(analysis.get("keywords"))); + + List> chapters = toMapList(analysis.get("chapters")); + if (!chapters.isEmpty()) { + List lines = new ArrayList<>(); + for (Map item : chapters) { + String prefix = asText(item.get("time")); + String title = asText(item.get("title")); + String summary = asText(item.get("summary")); + lines.add((prefix.isBlank() ? "" : prefix + " ") + title + (summary.isBlank() ? "" : ":" + summary)); + } + appendSection(builder, "章节速览", lines); + } + + List> speakerSummaries = toMapList(analysis.get("speakerSummaries")); + if (!speakerSummaries.isEmpty()) { + List lines = new ArrayList<>(); + for (Map item : speakerSummaries) { + lines.add(asText(item.get("speaker")) + ":" + asText(item.get("summary"))); + } + appendSection(builder, "发言总结", lines); + } + + List> keyPoints = toMapList(analysis.get("keyPoints")); + if (!keyPoints.isEmpty()) { + List lines = new ArrayList<>(); + for (Map item : keyPoints) { + StringBuilder line = new StringBuilder(asText(item.get("title"))); + String summary = asText(item.get("summary")); + if (!summary.isBlank()) { + line.append(":").append(summary); + } + String speaker = asText(item.get("speaker")); + String time = asText(item.get("time")); + if (!speaker.isBlank() || !time.isBlank()) { + line.append("("); + if (!speaker.isBlank()) { + line.append(speaker); + } + if (!speaker.isBlank() && !time.isBlank()) { + line.append(" / "); + } + if (!time.isBlank()) { + line.append(time); + } + line.append(")"); + } + lines.add(line.toString()); + } + appendSection(builder, "要点回顾", lines); + } + + appendSection(builder, "待办事项", normalizeStringList(analysis.get("todos"))); + return builder.toString().trim(); + } + @Override public void updateSummaryContent(Meeting meeting, String summaryContent) { AiTask summaryTask = findLatestSummaryTask(meeting); @@ -97,10 +262,10 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService private AiTask findLatestSummaryTask(Meeting meeting) { AiTask summaryTask = null; if (meeting.getLatestSummaryTaskId() != null) { - summaryTask = aiTaskService.getById(meeting.getLatestSummaryTaskId()); + summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId()); } if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { - summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper() .eq(AiTask::getMeetingId, meeting.getId()) .eq(AiTask::getTaskType, "SUMMARY") .eq(AiTask::getStatus, 2) @@ -111,6 +276,165 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService return summaryTask; } + private Object extractSummaryContent(AiTask task) { + if (task.getResponseData() == null) { + return null; + } + Object choices = task.getResponseData().get("choices"); + if (choices instanceof List choiceList && !choiceList.isEmpty()) { + Object first = choiceList.get(0); + if (first instanceof Map firstMap) { + Object message = firstMap.get("message"); + if (message instanceof Map messageMap) { + return messageMap.get("content"); + } + } + } + return null; + } + + private Map tryParseJson(String text) { + Map parsed = tryReadMap(text); + if (parsed != null) { + return parsed; + } + + int fenceStart = text.indexOf("```"); + if (fenceStart >= 0) { + int firstBreak = text.indexOf('\n', fenceStart); + int lastFence = text.lastIndexOf("```"); + if (firstBreak > fenceStart && lastFence > firstBreak) { + parsed = tryReadMap(text.substring(firstBreak + 1, lastFence).trim()); + if (parsed != null) { + return parsed; + } + } + } + + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start >= 0 && end > start) { + return tryReadMap(text.substring(start, end + 1)); + } + return null; + } + + private Map tryReadMap(String text) { + try { + return objectMapper.readValue(text, new TypeReference>() {}); + } catch (Exception ex) { + return null; + } + } + + private List normalizeStringList(Object value) { + List result = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + String text = asText(item); + if (!text.isBlank() && !result.contains(text)) { + result.add(text); + } + } + } + return result; + } + + private List> normalizeChapterList(Object value) { + List> result = new ArrayList<>(); + for (Map item : toMapList(value)) { + String title = asText(item.get("title")); + String summary = asText(item.get("summary")); + if (title.isBlank() && summary.isBlank()) { + continue; + } + Map normalized = new LinkedHashMap<>(); + normalized.put("time", asText(item.get("time"))); + normalized.put("title", title); + normalized.put("summary", summary); + result.add(normalized); + } + return result; + } + + private List> normalizeSpeakerSummaries(Object value) { + List> result = new ArrayList<>(); + for (Map item : toMapList(value)) { + String speaker = asText(item.get("speaker")); + String summary = asText(item.get("summary")); + if (speaker.isBlank() && summary.isBlank()) { + continue; + } + Map normalized = new LinkedHashMap<>(); + normalized.put("speaker", speaker); + normalized.put("summary", summary); + result.add(normalized); + } + return result; + } + + private List> normalizeKeyPoints(Object value) { + List> result = new ArrayList<>(); + for (Map item : toMapList(value)) { + String title = asText(item.get("title")); + String summary = asText(item.get("summary")); + if (title.isBlank() && summary.isBlank()) { + continue; + } + Map normalized = new LinkedHashMap<>(); + normalized.put("title", title); + normalized.put("summary", summary); + normalized.put("speaker", asText(item.get("speaker"))); + normalized.put("time", asText(item.get("time"))); + result.add(normalized); + } + return result; + } + + private List> toMapList(Object value) { + List> result = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + if (item instanceof Map map) { + result.add(objectMapper.convertValue(map, new TypeReference>() {})); + } + } + } + return result; + } + + private String asText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String clipText(String text, int limit) { + if (text == null) { + return ""; + } + String normalized = text.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= limit) { + return normalized; + } + return normalized.substring(0, limit).trim() + "..."; + } + + private void appendSection(StringBuilder builder, String title, List lines) { + List normalized = lines.stream() + .map(this::asText) + .filter(item -> !item.isBlank()) + .collect(Collectors.toList()); + if (normalized.isEmpty()) { + return; + } + if (builder.length() > 0) { + builder.append("\n\n"); + } + builder.append("## ").append(title).append("\n\n"); + for (String line : normalized) { + builder.append("- ").append(line).append("\n"); + } + } + private String extractFrontMatter(String markdown, Meeting meeting, AiTask summaryTask) { if (markdown != null && markdown.startsWith("---")) { int second = markdown.indexOf("\n---", 3); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index a81151c..f6219de 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -13,6 +13,14 @@ export interface MeetingVO { tags: string; audioUrl: string; summaryContent: string; + analysis?: { + overview?: string; + keywords?: string[]; + chapters?: Array<{ time?: string; title?: string; summary?: string }>; + speakerSummaries?: Array<{ speaker?: string; summary?: string }>; + keyPoints?: Array<{ title?: string; summary?: string; speaker?: string; time?: string }>; + todos?: string[]; + }; status: number; createdAt: string; } diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 3cb1b12..f1c4972 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1,63 +1,275 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { - Card, - Row, - Col, - Typography, - Tag, - Space, - Divider, - Button, - Skeleton, - Empty, - List, Avatar, Breadcrumb, - Popover, - Input, - Select, - message, + Button, + Card, + Col, + Divider, Drawer, + Empty, Form, + Input, + List, + message, Modal, + Popover, Progress, + Row, + Select, + Skeleton, + Space, + Tag, + Typography, } from 'antd'; import { - LeftOutlined, - UserOutlined, - ClockCircleOutlined, AudioOutlined, - RobotOutlined, - LoadingOutlined, - EditOutlined, - SyncOutlined, + CaretRightFilled, + ClockCircleOutlined, DownloadOutlined, + EditOutlined, + FastForwardOutlined, + LeftOutlined, + LoadingOutlined, + PauseOutlined, + RobotOutlined, + SyncOutlined, + UserOutlined, } from '@ant-design/icons'; -import ReactMarkdown from 'react-markdown'; import dayjs from 'dayjs'; +import ReactMarkdown from 'react-markdown'; import { + downloadMeetingSummary, getMeetingDetail, + getMeetingProgress, getTranscripts, - updateSpeakerInfo, + MeetingProgress, + MeetingTranscriptVO, + MeetingVO, reSummary, updateMeetingBasic, updateMeetingSummary, - MeetingVO, - MeetingTranscriptVO, - getMeetingProgress, - MeetingProgress, - downloadMeetingSummary, + updateSpeakerInfo, } from '../../api/business/meeting'; -import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; +import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; -import { useDict } from '../../hooks/useDict'; import { listUsers } from '../../api'; +import { useDict } from '../../hooks/useDict'; import { SysUser } from '../../types'; const { Title, Text } = Typography; const { Option } = Select; +type AnalysisChapter = { + time?: string; + title: string; + summary: string; +}; + +type AnalysisSpeakerSummary = { + speaker: string; + summary: string; +}; + +type AnalysisKeyPoint = { + title: string; + summary: string; + speaker?: string; + time?: string; +}; + +type MeetingAnalysis = { + overview: string; + keywords: string[]; + chapters: AnalysisChapter[]; + speakerSummaries: AnalysisSpeakerSummary[]; + keyPoints: AnalysisKeyPoint[]; + todos: string[]; +}; + +const ANALYSIS_EMPTY: MeetingAnalysis = { + overview: '', + keywords: [], + chapters: [], + speakerSummaries: [], + keyPoints: [], + todos: [], +}; + +const splitLines = (value?: string | null) => + (value || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + +const parseLooseJson = (raw?: string | null) => { + const input = (raw || '').trim(); + if (!input) return null; + + const tryParse = (text: string) => { + try { + return JSON.parse(text); + } catch { + return null; + } + }; + + const direct = tryParse(input); + if (direct && typeof direct === 'object') return direct; + + const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim(); + if (fenced) { + const fencedParsed = tryParse(fenced); + if (fencedParsed && typeof fencedParsed === 'object') return fencedParsed; + } + + const start = input.indexOf('{'); + const end = input.lastIndexOf('}'); + if (start >= 0 && end > start) { + const wrapped = tryParse(input.slice(start, end + 1)); + if (wrapped && typeof wrapped === 'object') return wrapped; + } + + return null; +}; + +const extractSection = (markdown: string, aliases: string[]) => { + const lines = markdown.split(/\r?\n/); + const lowerAliases = aliases.map((item) => item.toLowerCase()); + const cleanHeading = (line: string) => line.replace(/^#{1,6}\s*/, '').trim().toLowerCase(); + + let start = -1; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line.startsWith('#')) continue; + const heading = cleanHeading(line); + if (lowerAliases.some((alias) => heading.includes(alias))) { + start = index + 1; + break; + } + } + + if (start < 0) return ''; + + const buffer: string[] = []; + for (let index = start; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim().startsWith('#')) break; + buffer.push(line); + } + return buffer.join('\n').trim(); +}; + +const parseBulletList = (content?: string | null) => + splitLines(content) + .map((line) => line.replace(/^[-*•\s]+/, '').replace(/^\d+[.)]\s*/, '').trim()) + .filter(Boolean); + +const parseOverviewSection = (markdown: string) => + extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim(); + +const parseKeywordsSection = (markdown: string, tags: string) => { + const section = extractSection(markdown, ['关键词', '关键字', '标签']); + const fromSection = parseBulletList(section) + .flatMap((line) => line.split(/[,、,]/)) + .map((item) => item.trim()) + .filter(Boolean); + + if (fromSection.length) { + return Array.from(new Set(fromSection)).slice(0, 12); + } + + return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12); +}; + +const buildMeetingAnalysis = ( + sourceAnalysis: MeetingVO['analysis'] | undefined, + summaryContent: string | undefined, + tags: string, +): MeetingAnalysis => { + const parseStructured = (parsed: Record): MeetingAnalysis => { + const chapters = Array.isArray(parsed.chapters) ? parsed.chapters : []; + const speakerSummaries = Array.isArray(parsed.speakerSummaries) ? parsed.speakerSummaries : []; + const keyPoints = Array.isArray(parsed.keyPoints) ? parsed.keyPoints : []; + const todos = Array.isArray(parsed.todos) + ? parsed.todos + : Array.isArray(parsed.actionItems) + ? parsed.actionItems + : []; + + return { + overview: String(parsed.overview || '').trim(), + keywords: Array.from( + new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)), + ).slice(0, 12), + chapters: chapters + .map((item: any) => ({ + time: item?.time ? String(item.time).trim() : undefined, + title: String(item?.title || '').trim(), + summary: String(item?.summary || '').trim(), + })) + .filter((item: AnalysisChapter) => item.title || item.summary), + speakerSummaries: speakerSummaries + .map((item: any) => ({ + speaker: String(item?.speaker || '').trim(), + summary: String(item?.summary || '').trim(), + })) + .filter((item: AnalysisSpeakerSummary) => item.speaker || item.summary), + keyPoints: keyPoints + .map((item: any) => ({ + title: String(item?.title || '').trim(), + summary: String(item?.summary || '').trim(), + speaker: item?.speaker ? String(item.speaker).trim() : undefined, + time: item?.time ? String(item.time).trim() : undefined, + })) + .filter((item: AnalysisKeyPoint) => item.title || item.summary), + todos: todos.map((item: any) => String(item).trim()).filter(Boolean).slice(0, 10), + }; + }; + + if (sourceAnalysis) { + return parseStructured(sourceAnalysis as Record); + } + + const raw = (summaryContent || '').trim(); + if (!raw && !tags) return ANALYSIS_EMPTY; + + const loose = parseLooseJson(raw); + if (loose) { + return parseStructured(loose); + } + + return { + overview: parseOverviewSection(raw), + keywords: parseKeywordsSection(raw, tags), + chapters: [], + speakerSummaries: [], + keyPoints: [], + todos: [], + }; +}; + +function formatTime(ms: number) { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; +} + +function formatPlayerTime(seconds: number) { + const safeSeconds = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainSeconds = safeSeconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; + } + + return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; +} + const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => { const [progress, setProgress] = useState(null); @@ -71,8 +283,8 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo onComplete(); } } - } catch (err) { - // ignore polling errors + } catch { + // ignore } }; @@ -84,12 +296,12 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo const percent = progress?.percent || 0; const isError = percent < 0; - const formatETA = (seconds?: number) => { + const formatEta = (seconds?: number) => { if (!seconds || seconds <= 0) return '正在分析中'; - if (seconds < 60) return `${seconds}秒`; - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return s > 0 ? `${m}分${s}秒` : `${m}分钟`; + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`; }; return ( @@ -106,23 +318,22 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo }} >
- AI 智能分析中 + + AI 智能分析中 +
- + {progress?.message || '正在准备计算资源...'} - 分析过程中,请耐心等待,你可以先去处理其他工作 + 分析过程中请稍候,你可以先处理其他工作。
@@ -135,15 +346,13 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo 预计剩余 - {isError ? '--' : formatETA(progress?.eta)} + {isError ? '--' : formatEta(progress?.eta)} 任务状态 - - {isError ? '已中断' : '正常'} - + {isError ? '已中断' : '正常'} @@ -164,42 +373,29 @@ const SpeakerEditor: React.FC<{ const [loading, setLoading] = useState(false); const { items: speakerLabels } = useDict('biz_speaker_label'); - const handleSave = async (e: React.MouseEvent) => { - e.stopPropagation(); + const handleSave = async (event: React.MouseEvent) => { + event.stopPropagation(); setLoading(true); try { await updateSpeakerInfo({ meetingId, speakerId, newName: name, label }); - message.success('发言人信息已全局更新'); + message.success('发言人信息已更新'); onSuccess(); - } catch (err) { - console.error(err); + } catch (error) { + console.error(error); } finally { setLoading(false); } }; return ( -
e.stopPropagation()}> +
event.stopPropagation()}>
发言人姓名 - setName(e.target.value)} - placeholder="输入姓名" - size="small" - style={{ marginTop: 4 }} - /> + setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
角色标签 - {speakerLabels.map((item) => ( {item.itemLabel} @@ -227,10 +423,15 @@ const MeetingDetail: React.FC = () => { const [summaryVisible, setSummaryVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null); - const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); - + const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); + const [expandKeywords, setExpandKeywords] = useState(false); + const [expandSummary, setExpandSummary] = useState(false); + const [audioCurrentTime, setAudioCurrentTime] = useState(0); + const [audioDuration, setAudioDuration] = useState(0); + const [audioPlaying, setAudioPlaying] = useState(false); + const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); @@ -239,7 +440,21 @@ const MeetingDetail: React.FC = () => { const audioRef = useRef(null); const summaryPdfRef = useRef(null); - const isOwner = React.useMemo(() => { + const analysis = useMemo( + () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''), + [meeting?.analysis, meeting?.summaryContent, meeting?.tags], + ); + const hasAnalysis = !!( + analysis.overview || + analysis.keywords.length || + analysis.chapters.length || + analysis.speakerSummaries.length || + analysis.keyPoints.length || + analysis.todos.length + ); + const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9); + + const isOwner = useMemo(() => { if (!meeting) return false; const profileStr = sessionStorage.getItem('userProfile'); if (profileStr) { @@ -248,23 +463,54 @@ const MeetingDetail: React.FC = () => { } return false; }, [meeting]); + const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2; useEffect(() => { - if (id) { - fetchData(Number(id)); - loadAiConfigs(); - loadUsers(); - } + if (!id) return; + fetchData(Number(id)); + loadAiConfigs(); + loadUsers(); }, [id]); + 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]); + const fetchData = async (meetingId: number) => { try { const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); - } catch (err) { - console.error(err); + } catch (error) { + console.error(error); } finally { setLoading(false); } @@ -272,15 +518,15 @@ const MeetingDetail: React.FC = () => { const loadAiConfigs = async () => { try { - const [mRes, pRes, dRes] = await Promise.all([ + const [modelRes, promptRes, defaultRes] = await Promise.all([ getAiModelPage({ current: 1, size: 100, type: 'LLM' }), getPromptPage({ current: 1, size: 100 }), getAiModelDefault('LLM'), ]); - setLlmModels(mRes.data.data.records.filter((m) => m.status === 1)); - setPrompts(pRes.data.data.records.filter((p) => p.status === 1)); - summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id }); - } catch (e) { + setLlmModels(modelRes.data.data.records.filter((item) => item.status === 1)); + setPrompts(promptRes.data.data.records.filter((item) => item.status === 1)); + summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id }); + } catch { // ignore } }; @@ -289,7 +535,7 @@ const MeetingDetail: React.FC = () => { try { const users = await listUsers(); setUserList(users || []); - } catch (err) { + } catch { // ignore } }; @@ -304,19 +550,19 @@ const MeetingDetail: React.FC = () => { }; const handleUpdateBasic = async () => { - const vals = await form.validateFields(); + const values = await form.validateFields(); setActionLoading(true); try { await updateMeetingBasic({ - ...vals, + ...values, meetingId: meeting?.id, - tags: vals.tags?.join(','), + tags: values.tags?.join(','), }); message.success('会议信息已更新'); setEditVisible(false); fetchData(Number(id)); - } catch (err) { - console.error(err); + } catch (error) { + console.error(error); } finally { setActionLoading(false); } @@ -332,71 +578,88 @@ const MeetingDetail: React.FC = () => { message.success('总结内容已更新'); setIsEditingSummary(false); fetchData(Number(id)); - } catch (err) { - console.error(err); + } catch (error) { + console.error(error); } finally { setActionLoading(false); } }; const handleReSummary = async () => { - const vals = await summaryForm.validateFields(); + const values = await summaryForm.validateFields(); setActionLoading(true); try { await reSummary({ meetingId: Number(id), - summaryModelId: vals.summaryModelId, - promptId: vals.promptId, + summaryModelId: values.summaryModelId, + promptId: values.promptId, }); message.success('已重新发起总结任务'); setSummaryVisible(false); fetchData(Number(id)); - } catch (err) { - console.error(err); + } catch (error) { + console.error(error); } finally { setActionLoading(false); } }; - const formatTime = (ms: number) => { - const seconds = Math.floor(ms / 1000); - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + const seekTo = (timeMs: number) => { + if (!audioRef.current) return; + audioRef.current.currentTime = timeMs / 1000; + audioRef.current.play(); }; - const seekTo = (timeMs: number) => { - if (audioRef.current) { - audioRef.current.currentTime = timeMs / 1000; + const toggleAudioPlayback = () => { + if (!audioRef.current) return; + if (audioRef.current.paused) { audioRef.current.play(); + } else { + audioRef.current.pause(); } }; + const handleAudioProgressChange = (event: React.ChangeEvent) => { + const nextTime = Number(event.target.value || 0); + setAudioCurrentTime(nextTime); + if (audioRef.current) { + audioRef.current.currentTime = nextTime; + } + }; + + const cyclePlaybackRate = () => { + if (!audioRef.current) return; + const rates = [1, 1.25, 1.5, 2]; + const currentIndex = rates.findIndex((item) => item === audioPlaybackRate); + const nextRate = rates[(currentIndex + 1) % rates.length]; + audioRef.current.playbackRate = nextRate; + setAudioPlaybackRate(nextRate); + }; + const getFileNameFromDisposition = (disposition?: string, fallback?: string) => { if (!disposition) return fallback || 'summary'; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); - const normalMatch = disposition.match(/filename=\"?([^\";]+)\"?/i); + const normalMatch = disposition.match(/filename="?([^";]+)"?/i); return normalMatch?.[1] || fallback || 'summary'; }; const handleDownloadSummary = async (format: 'pdf' | 'word') => { if (!meeting) return; if (!meeting.summaryContent) { - message.warning('当前暂无可下载的AI总结'); + message.warning('当前暂无可下载的 AI 总结'); return; } try { setDownloadLoading(format); const res = await downloadMeetingSummary(meeting.id, format); - const contentType: string = + const contentType = res.headers['content-type'] || (format === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); - // 后端若返回业务错误,可能是 JSON Blob,不能当文件保存 if (contentType.includes('application/json')) { const text = await (res.data as Blob).text(); try { @@ -410,51 +673,71 @@ const MeetingDetail: React.FC = () => { const blob = new Blob([res.data], { type: contentType }); const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = getFileNameFromDisposition( + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = getFileNameFromDisposition( res.headers['content-disposition'], - `${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`, + `${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`, ); - document.body.appendChild(a); - a.click(); - a.remove(); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); window.URL.revokeObjectURL(url); - } catch (err) { - console.error(err); - message.error(`${format.toUpperCase()}下载失败`); + } catch (error) { + console.error(error); + message.error(`${format.toUpperCase()} 下载失败`); } finally { setDownloadLoading(null); } }; - if (loading) return
; - if (!meeting) return
; + if (loading) { + return ( +
+ +
+ ); + } + + if (!meeting) { + return ( +
+ +
+ ); + } return ( -
- - navigate('/meetings')}>会议中心 +
+ + + navigate('/meetings')}>会议中心 + 会议详情 - + {meeting.title} {isOwner && ( - <EditOutlined - style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} - onClick={handleEditMeeting} - /> + <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} /> )} }> - {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} - {meeting.tags?.split(',').filter(Boolean).map((t) => {t})} - {meeting.participants || '未指定'} + + {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} + + + {meeting.tags?.split(',').filter(Boolean).map((tag) => ( + {tag} + ))} + + + {meeting.participants || '未指定'} + @@ -473,14 +756,16 @@ const MeetingDetail: React.FC = () => { {meeting.status === 3 && !!meeting.summaryContent && ( <> )} - + @@ -491,24 +776,175 @@ const MeetingDetail: React.FC = () => { fetchData(meeting.id)} /> ) : ( - - 语音转录} - style={{ height: '100%', display: 'flex', flexDirection: 'column' }} - bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} - extra={meeting.audioUrl &&