From 61da0504388ce63b7d0c4b05ad05e58b8931b578 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 5 Mar 2026 17:52:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=BC=E5=87=BApdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 10 + .../controller/biz/MeetingController.java | 447 +++++++++++++++++- .../java/com/imeeting/dto/biz/MeetingVO.java | 1 + .../service/biz/impl/AiModelServiceImpl.java | 62 ++- .../service/biz/impl/MeetingServiceImpl.java | 1 + frontend/package-lock.json | 213 +++++++++ frontend/package.json | 2 + frontend/src/api/business/meeting.ts | 11 + frontend/src/pages/Dashboard.tsx | 110 +++-- frontend/src/pages/business/MeetingDetail.tsx | 447 ++++++++++++++---- frontend/src/pages/business/Meetings.tsx | 12 +- frontend/src/routes/routes.tsx | 3 +- 12 files changed, 1141 insertions(+), 178 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index 3a3334e..9a9949f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -94,6 +94,16 @@ spring-boot-starter-test test + + org.apache.pdfbox + pdfbox + 2.0.30 + + + org.apache.poi + poi-ooxml + 5.2.5 + diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index a3f51d4..116e0b5 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -2,26 +2,46 @@ package com.imeeting.controller.biz; import com.imeeting.common.ApiResponse; import com.imeeting.common.PageResult; +import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.MeetingDTO; -import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingTranscriptVO; +import com.imeeting.dto.biz.MeetingVO; import com.imeeting.entity.biz.Meeting; import com.imeeting.security.LoginUser; import com.imeeting.service.biz.MeetingService; -import com.imeeting.common.RedisKeys; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.apache.fontbox.ttf.TrueTypeCollection; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @RestController @RequestMapping("/api/biz/meeting") @@ -31,7 +51,7 @@ public class MeetingController { private final StringRedisTemplate redisTemplate; private final String uploadPath; - public MeetingController(MeetingService meetingService, + public MeetingController(MeetingService meetingService, StringRedisTemplate redisTemplate, @Value("${app.upload-path}") String uploadPath) { this.meetingService = meetingService; @@ -46,14 +66,12 @@ public class MeetingController { String json = redisTemplate.opsForValue().get(key); if (json != null) { try { - // 直接返回 Redis 中的进度 JSON return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class)); } catch (Exception e) { return ApiResponse.error("解析进度异常"); } } - - // 如果 Redis 没数据,根据数据库状态返回 + Meeting m = meetingService.getById(id); Map fallback = new HashMap<>(); if (m != null) { @@ -79,9 +97,9 @@ public class MeetingController { File dir = new File(uploadDir); if (!dir.exists()) dir.mkdirs(); - String fileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); file.transferTo(new File(uploadDir + fileName)); - + return ApiResponse.ok("/api/static/audio/" + fileName); } @@ -102,12 +120,12 @@ public class MeetingController { @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String title, @RequestParam(defaultValue = "all") String viewType) { - + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); - - return ApiResponse.ok(meetingService.pageMeetings(current, size, title, - loginUser.getTenantId(), loginUser.getUserId(), + + return ApiResponse.ok(meetingService.pageMeetings(current, size, title, + loginUser.getTenantId(), loginUser.getUserId(), loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(), viewType, isAdmin)); } @@ -118,6 +136,48 @@ public class MeetingController { return ApiResponse.ok(meetingService.getDetail(id)); } + @GetMapping("/{id}/summary/export") + @PreAuthorize("isAuthenticated()") + public ResponseEntity exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) { + MeetingVO meeting = meetingService.getDetail(id); + if (meeting == null) { + throw new RuntimeException("数据未找到,请刷新后重试"); + } + if (meeting.getSummaryContent() == null || meeting.getSummaryContent().trim().isEmpty()) { + throw new RuntimeException(" AI总结为空"); + } + + String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty()) + ? "meeting-summary-" + id + : meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_"); + + try { + byte[] bytes; + String ext; + String contentType; + if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) { + bytes = buildWordBytes(meeting); + ext = "docx"; + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } else if ("pdf".equalsIgnoreCase(format)) { + bytes = buildPdfBytes(meeting); + ext = "pdf"; + contentType = MediaType.APPLICATION_PDF_VALUE; + } else { + throw new RuntimeException("格式化失败"); + } + + String filename = safeTitle + "-AI-总结." + ext; + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename) + .contentType(MediaType.parseMediaType(contentType)) + .body(bytes); + } catch (IOException e) { + throw new RuntimeException("导出失败 " + e.getMessage(), e); + } + } + @GetMapping("/transcripts/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse> getTranscripts(@PathVariable Long id) { @@ -131,7 +191,7 @@ public class MeetingController { String speakerId = params.get("speakerId").toString(); String newName = params.get("newName") != null ? params.get("newName").toString() : null; String label = params.get("label") != null ? params.get("label").toString() : null; - + meetingService.updateSpeakerInfo(meetingId, speakerId, newName, label); return ApiResponse.ok(true); } @@ -142,7 +202,7 @@ public class MeetingController { Long meetingId = Long.valueOf(params.get("meetingId").toString()); Long summaryModelId = Long.valueOf(params.get("summaryModelId").toString()); Long promptId = Long.valueOf(params.get("promptId").toString()); - + meetingService.reSummary(meetingId, summaryModelId, promptId); return ApiResponse.ok(true); } @@ -154,12 +214,12 @@ public class MeetingController { Meeting existing = meetingService.getById(meeting.getId()); if (existing == null) return ApiResponse.error("会议不存在"); - // 权限校验:仅发起人或管理员可修改 - if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + if (!existing.getCreatorId().equals(loginUser.getUserId()) + && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) + && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { return ApiResponse.error("无权修改此会议信息"); } - // 仅允许修改标题、人员、标签等基本信息 return ApiResponse.ok(meetingService.updateById(meeting)); } @@ -170,11 +230,360 @@ public class MeetingController { Meeting existing = meetingService.getById(id); if (existing == null) return ApiResponse.ok(true); - if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + if (!existing.getCreatorId().equals(loginUser.getUserId()) + && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) + && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { return ApiResponse.error("无权删除此会议"); } meetingService.deleteMeeting(id); return ApiResponse.ok(true); } + + private byte[] buildWordBytes(MeetingVO meeting) throws IOException { + try (XWPFDocument document = new XWPFDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + XWPFParagraph title = document.createParagraph(); + XWPFRun titleRun = title.createRun(); + titleRun.setBold(true); + titleRun.setFontSize(16); + titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI 总结"); + + XWPFParagraph timeP = document.createParagraph(); + timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime())); + + XWPFParagraph participantsP = document.createParagraph(); + participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants())); + + document.createParagraph(); + + for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) { + XWPFParagraph p = document.createParagraph(); + if (block.type == MdType.HEADING) { + int size = Math.max(12, 18 - (block.level - 1) * 2); + appendMarkdownRuns(p, block.text, true, size); + } else if (block.type == MdType.LIST) { + p.setIndentationLeft(360); + XWPFRun bullet = p.createRun(); + bullet.setFontSize(12); + bullet.setText("- "); + appendMarkdownRuns(p, block.text, false, 12); + } else { + appendMarkdownRuns(p, block.text, false, 12); + } + } + + document.write(out); + return out.toByteArray(); + } + } + + private byte[] buildPdfBytes(MeetingVO meeting) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + List fonts = loadPdfFonts(document); + final float margin = 50f; + final float pageWidth = PDRectangle.A4.getWidth() - 2 * margin; + + PdfCtx ctx = newPdfPage(document, margin); + ctx = writeWrappedPdf(document, ctx, (meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary", + fonts, 14f, 18f, margin, pageWidth); + ctx = writeWrappedPdf(document, ctx, "Meeting Time: " + meeting.getMeetingTime(), fonts, 11f, 15f, margin, pageWidth); + ctx = writeWrappedPdf(document, ctx, "Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants()), fonts, 11f, 15f, margin, pageWidth); + ctx.y -= 10f; + + for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) { + if (block.type == MdType.HEADING) { + int size = Math.max(12, 18 - (block.level - 1) * 2); + ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, size, size + 4f, margin, pageWidth); + ctx.y -= 4f; + } else if (block.type == MdType.LIST) { + ctx = writeWrappedPdf(document, ctx, "- " + toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth); + } else { + ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth); + ctx.y -= 2f; + } + } + + ctx.content.close(); + document.save(out); + return out.toByteArray(); + } + } + + private void appendMarkdownRuns(XWPFParagraph p, String text, boolean defaultBold, int size) { + String input = text == null ? "" : text; + Matcher m = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); + int start = 0; + while (m.find()) { + String normal = toPlainInline(input.substring(start, m.start())); + if (!normal.isEmpty()) { + XWPFRun run = p.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(normal); + } + String boldText = toPlainInline(m.group(1)); + if (!boldText.isEmpty()) { + XWPFRun run = p.createRun(); + run.setBold(true); + run.setFontSize(size); + run.setText(boldText); + } + start = m.end(); + } + String tail = toPlainInline(input.substring(start)); + if (!tail.isEmpty()) { + XWPFRun run = p.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(tail); + } + } + + private PdfCtx writeWrappedPdf(PDDocument document, PdfCtx ctx, String text, List fonts, float fontSize, + float lineHeight, float margin, float maxWidth) throws IOException { + for (String line : wrapByWidth(text, maxWidth, fonts, fontSize)) { + if (ctx.y < margin + lineHeight) { + ctx.content.close(); + ctx = newPdfPage(document, margin); + } + ctx.content.beginText(); + ctx.content.newLineAtOffset(margin, ctx.y); + writeLineWithFontFallback(ctx.content, line, fonts, fontSize); + ctx.content.endText(); + ctx.y -= lineHeight; + } + return ctx; + } + + private List wrapByWidth(String text, float maxWidth, List fonts, float fontSize) throws IOException { + String content = text == null ? "" : text; + if (content.isEmpty()) return List.of(""); + + List lines = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + float currentWidth = 0f; + for (int i = 0; i < content.length(); ) { + int codePoint = content.codePointAt(i); + String ch = normalizePdfChar(codePoint); + PDFont font = pickFontForChar(ch, fonts); + if (font == null) { + ch = "?"; + font = pickFontForChar(ch, fonts); + } + if (font == null) { + i += Character.charCount(codePoint); + continue; + } + float charWidth = getCharWidth(font, ch, fontSize); + + if (currentWidth + charWidth <= maxWidth || current.length() == 0) { + current.append(ch); + currentWidth += charWidth; + } else { + lines.add(current.toString()); + current.setLength(0); + current.append(ch); + currentWidth = charWidth; + } + i += Character.charCount(codePoint); + } + if (current.length() > 0) { + lines.add(current.toString()); + } + return lines; + } + + private List loadPdfFonts(PDDocument document) { + List fonts = new ArrayList<>(); + String[] candidates = new String[]{ + "C:/Windows/Fonts/msyh.ttf", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/simsun.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf" + }; + + for (String path : candidates) { + try { + File file = new File(path); + if (!file.exists()) continue; + if (path.toLowerCase().endsWith(".ttc")) { + try (TrueTypeCollection ttc = new TrueTypeCollection(file)) { + ttc.processAllFonts(font -> { + try { + fonts.add(PDType0Font.load(document, font, true)); + } catch (Exception ignored) { + } + }); + } + } else { + fonts.add(PDType0Font.load(document, file)); + } + } catch (Exception ignored) { + } + } + if (fonts.isEmpty()) { + fonts.add(PDType1Font.HELVETICA); + } + return fonts; + } + + private List parseMarkdownBlocks(String markdown) { + List blocks = new ArrayList<>(); + if (markdown == null || markdown.trim().isEmpty()) { + return blocks; + } + + String[] lines = markdown.replace("\r\n", "\n").split("\n"); + StringBuilder paragraph = new StringBuilder(); + + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty()) { + flushParagraph(blocks, paragraph); + continue; + } + if (line.startsWith("#")) { + flushParagraph(blocks, paragraph); + int level = 0; + while (level < line.length() && line.charAt(level) == '#') { + level++; + } + level = Math.min(level, 6); + String text = line.substring(level).trim(); + blocks.add(new MdBlock(MdType.HEADING, level, text)); + continue; + } + if (line.startsWith("- ") || line.startsWith("* ")) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); + continue; + } + Matcher ordered = Pattern.compile("^\\d+\\.\\s+(.*)$").matcher(line); + if (ordered.find()) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, ordered.group(1).trim())); + continue; + } + + if (paragraph.length() > 0) paragraph.append(' '); + paragraph.append(line); + } + flushParagraph(blocks, paragraph); + return blocks; + } + + private void flushParagraph(List blocks, StringBuilder paragraph) { + if (paragraph.length() > 0) { + blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString())); + paragraph.setLength(0); + } + } + + private String toPlainInline(String input) { + if (input == null) return ""; + return input + .replaceAll("`([^`]+)`", "$1") + .replaceAll("\\*\\*(.*?)\\*\\*", "$1") + .replaceAll("\\*(.*?)\\*", "$1") + .replaceAll("\\[(.*?)]\\((.*?)\\)", "$1"); + } + + private void writeLineWithFontFallback(PDPageContentStream content, String line, List fonts, float fontSize) throws IOException { + if (line == null || line.isEmpty()) return; + PDFont currentFont = null; + StringBuilder segment = new StringBuilder(); + for (int i = 0; i < line.length(); ) { + int codePoint = line.codePointAt(i); + String ch = normalizePdfChar(codePoint); + PDFont font = pickFontForChar(ch, fonts); + if (font == null) { + ch = "?"; + font = pickFontForChar(ch, fonts); + } + if (font == null) { + i += Character.charCount(codePoint); + continue; + } + if (currentFont == null) { + currentFont = font; + } + if (font != currentFont) { + content.setFont(currentFont, fontSize); + content.showText(segment.toString()); + segment.setLength(0); + currentFont = font; + } + segment.append(ch); + i += Character.charCount(codePoint); + } + if (segment.length() > 0) { + content.setFont(currentFont, fontSize); + content.showText(segment.toString()); + } + } + + private PDFont pickFontForChar(String ch, List fonts) { + for (PDFont font : fonts) { + try { + font.encode(ch); + return font; + } catch (Exception ignored) { + } + } + return null; + } + + private float getCharWidth(PDFont font, String ch, float fontSize) { + try { + return font.getStringWidth(ch) / 1000f * fontSize; + } catch (Exception e) { + return fontSize; + } + } + + private String normalizePdfChar(int codePoint) { + if (codePoint == 0x2022) return "-"; + if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) return " "; + return new String(Character.toChars(codePoint)); + } + + private PdfCtx newPdfPage(PDDocument document, float margin) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDPageContentStream content = new PDPageContentStream(document, page); + float y = page.getMediaBox().getHeight() - margin; + return new PdfCtx(content, y); + } + + private enum MdType { + HEADING, + LIST, + PARAGRAPH + } + + private static class MdBlock { + private final MdType type; + private final int level; + private final String text; + + private MdBlock(MdType type, int level, String text) { + this.type = type; + this.level = level; + this.text = text; + } + } + + private static class PdfCtx { + private final PDPageContentStream content; + private float y; + + private PdfCtx(PDPageContentStream content, float y) { + this.content = content; + this.y = y; + } + } } 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 e3ef373..e374d29 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -9,6 +9,7 @@ public class MeetingVO { private Long id; private Long tenantId; private Long creatorId; + private String creatorName; private String title; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index cfee23d..eead4e6 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -4,33 +4,49 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.PageResult; import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.entity.biz.AiModel; import com.imeeting.mapper.biz.AiModelMapper; import com.imeeting.service.biz.AiModelService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @Slf4j +@RequiredArgsConstructor public class AiModelServiceImpl extends ServiceImpl implements AiModelService { + private final ObjectMapper objectMapper; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + @Override @Transactional(rollbackFor = Exception.class) public AiModelVO saveModel(AiModelDTO dto) { AiModel entity = new AiModel(); copyProperties(dto, entity); - + + pushAsrConfig(entity); handleAsrWsUrl(entity); handleDefaultLogic(entity); - + this.save(entity); return toVO(entity); } @@ -40,11 +56,12 @@ public class AiModelServiceImpl extends ServiceImpl impl public AiModelVO updateModel(AiModelDTO dto) { AiModel entity = this.getById(dto.getId()); if (entity == null) throw new RuntimeException("Model not found"); - + copyProperties(dto, entity); + pushAsrConfig(entity); handleAsrWsUrl(entity); handleDefaultLogic(entity); - + this.updateById(entity); return toVO(entity); } @@ -167,6 +184,43 @@ public class AiModelServiceImpl extends ServiceImpl impl } } + private void pushAsrConfig(AiModel entity) { + if (!"ASR".equals(entity.getModelType())) { + return; + } + if (entity.getBaseUrl() == null || entity.getBaseUrl().trim().isEmpty()) { + throw new RuntimeException("baseUrl is required for ASR model"); + } + if (entity.getModelCode() == null || entity.getModelCode().trim().isEmpty()) { + throw new RuntimeException("modelCode is required for ASR model"); + } + + String targetUrl = entity.getBaseUrl().endsWith("/") + ? entity.getBaseUrl() + "api/asrconfig" + : entity.getBaseUrl() + "/api/asrconfig"; + + try { + Map body = new HashMap<>(); + body.put("asr_model_type", entity.getModelCode()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(100)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + log.error("Push ASR config failed, url={}, code={}, body={}", targetUrl, response.statusCode(), response.body()); + throw new RuntimeException("Third-party ASR config save failed: HTTP " + response.statusCode()); + } + } catch (Exception e) { + log.error("Push ASR config error, url={}, msg={}", targetUrl, e.getMessage(), e); + throw new RuntimeException("Third-party ASR config save failed: " + e.getMessage(), e); + } + } + private void copyProperties(AiModelDTO dto, AiModel entity) { entity.setModelType(dto.getModelType()); entity.setModelName(dto.getModelName()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index 0ce9e43..e27a2bb 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -212,6 +212,7 @@ public class MeetingServiceImpl extends ServiceImpl impl vo.setId(meeting.getId()); vo.setTenantId(meeting.getTenantId()); vo.setCreatorId(meeting.getCreatorId()); + vo.setCreatorName(meeting.getCreatorName()); vo.setTitle(meeting.getTitle()); vo.setMeetingTime(meeting.getMeetingTime()); vo.setTags(meeting.getTags()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a6973fd..fd5c76c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,10 @@ "@ant-design/icons": "^6.1.0", "antd": "^5.13.2", "axios": "^1.6.7", + "html2canvas": "^1.4.1", "i18next": "^25.8.6", "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.5.4", @@ -1506,12 +1508,25 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", @@ -1532,6 +1547,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", @@ -1677,6 +1699,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1755,6 +1786,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", @@ -1864,6 +1915,27 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -1937,6 +2009,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2068,6 +2150,23 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -2285,6 +2384,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/i18next": { "version": "25.8.6", "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.6.tgz", @@ -2331,6 +2443,12 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -2434,6 +2552,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", @@ -3123,6 +3258,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3148,6 +3289,13 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -3200,6 +3348,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/rc-cascader": { "version": "3.34.0", "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", @@ -3933,6 +4091,13 @@ "react-dom": ">=16.8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", @@ -3972,6 +4137,16 @@ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", @@ -4065,6 +4240,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", @@ -4109,6 +4294,25 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -4285,6 +4489,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0fdb20b..d0e43c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,10 @@ "@ant-design/icons": "^6.1.0", "antd": "^5.13.2", "axios": "^1.6.7", + "html2canvas": "^1.4.1", "i18next": "^25.8.6", "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.5.4", diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index bbd9441..e7f6241 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -1,9 +1,11 @@ import http from "../http"; +import axios from "axios"; export interface MeetingVO { id: number; tenantId: number; creatorId: number; + creatorName?: string; title: string; meetingTime: string; participants: string; @@ -114,3 +116,12 @@ export const getMeetingProgress = (id: number) => { `/api/biz/meeting/${id}/progress` ); }; + +export const downloadMeetingSummary = (id: number, format: 'pdf' | 'word') => { + const token = localStorage.getItem("accessToken"); + return axios.get(`/api/biz/meeting/${id}/summary/export`, { + params: { format }, + responseType: 'blob', + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); +}; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b1c93a6..573c32e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,10 +1,17 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd'; -import { - HistoryOutlined, CheckCircleOutlined, LoadingOutlined, - AudioOutlined, RobotOutlined, - CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined, - PlayCircleOutlined, FileTextOutlined +import { + HistoryOutlined, + CheckCircleOutlined, + LoadingOutlined, + AudioOutlined, + RobotOutlined, + CalendarOutlined, + TeamOutlined, + RiseOutlined, + ClockCircleOutlined, + PlayCircleOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; @@ -13,7 +20,6 @@ import { MeetingVO, getMeetingProgress, MeetingProgress } from '../api/business/ const { Title, Text } = Typography; -// 新增进度显示子组件 const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { const [progress, setProgress] = useState(null); @@ -23,10 +29,12 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) = const fetchProgress = async () => { try { const res = await getMeetingProgress(meeting.id); - if (res.data && res.data.data) { + if (res.data?.data) { setProgress(res.data.data); } - } catch (err) {} + } catch (err) { + // ignore + } }; fetchProgress(); @@ -42,15 +50,15 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) = return (
- + {progress?.message || '准备分析中...'} - {!isError && {percent}%} + {!isError && {percent}%}
- = ({ meeting }) = ); }; -const Dashboard: React.FC = () => { +export const Dashboard: React.FC = () => { const navigate = useNavigate(); const [stats, setStats] = useState(null); const [recentTasks, setRecentTasks] = useState([]); const [loading, setLoading] = useState(true); + const processingCount = Number(stats?.processingTasks || 0); + const dashboardLoading = loading && processingCount > 0; + useEffect(() => { fetchDashboardData(); - const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时 + const timer = setInterval(fetchDashboardData, 5000); return () => clearInterval(timer); }, []); const fetchDashboardData = async () => { try { - const [statsRes, tasksRes] = await Promise.all([ - getDashboardStats(), - getRecentTasks() - ]); + const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]); setStats(statsRes.data.data); setRecentTasks(tasksRes.data.data || []); } catch (err) { @@ -87,9 +95,8 @@ const Dashboard: React.FC = () => { }; const renderTaskProgress = (item: MeetingVO) => { - // 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status); - + return (
{ { title: '语音转录', icon: item.status === 1 ? : , - description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转换中' : '排队中') + description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中') }, { title: '智能总结', icon: item.status === 2 ? : , - description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在构思' : '待触发') + description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行') }, { title: '分析完成', @@ -117,74 +124,77 @@ const Dashboard: React.FC = () => { ); }; + const statCards = [ + { label: '累计会议记录', value: stats?.totalMeetings, icon: , color: '#1890ff' }, + { + label: '当前分析中任务', + value: stats?.processingTasks, + icon: processingCount > 0 ? : , + color: '#faad14' + }, + { label: '今日新增分析', value: stats?.todayNew, icon: , color: '#52c41a' }, + { label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: , color: '#13c2c2' }, + ]; + return (
- - {/* 顶部统计区 */} - {[ - { label: '累计会议记录', value: stats?.totalMeetings, icon: , color: '#1890ff' }, - { label: '当前分析中任务', value: stats?.processingTasks, icon: , color: '#faad14' }, - { label: '今日新增分析', value: stats?.todayNew, icon: , color: '#52c41a' }, - { label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: , color: '#13c2c2' }, - ].map((s, idx) => ( + {statCards.map((s, idx) => ( - {s.label}} - value={s.value || 0} + {s.label}} + value={s.value || 0} valueStyle={{ color: s.color, fontWeight: 700 }} - prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })} + prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })} /> ))} - {/* 核心任务流 - 垂直卡片列表 */} - 最近任务动态
} - bordered={false} + bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }} > (
- {/* 左:会议基础信息 */} navigate(`/meetings/${item.id}`)}> {item.title} }> - {dayjs(item.meetingTime).format('MM-DD HH:mm')} - {item.participants || '系统记录'} + {dayjs(item.meetingTime).format('MM-DD HH:mm')} + {item.participants || item.creatorName || '未指定'}
- {item.tags?.split(',').filter(Boolean).map(t => {t})} + {item.tags?.split(',').filter(Boolean).map((t) => ( + {t} + ))}
- {/* 中:线性进度条 */} {renderTaskProgress(item)} - {/* 右:操作入口 */} -
- - {/* 进度实时展示 */} +
@@ -203,6 +212,7 @@ const Dashboard: React.FC = () => { />
+ - {/* 修改基础信息弹窗 - 仅限 Owner */} {isOwner && ( - setEditVisible(false)} confirmLoading={actionLoading} width={600}> + setEditVisible(false)} + confirmLoading={actionLoading} + width={600} + >
- {llmModels.map(m => )} + {llmModels.map((m) => ( + + ))} - + {prompts.map((p) => )} - 提示:重新总结将基于当前的语音转录全文重新生成纪要,原有的总结内容将被覆盖。 + 提示:重新总结将基于当前语音转录全文重新生成纪要,原有总结内容会被覆盖。 )} diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 68aef7d..fe0ed22 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -353,15 +353,21 @@ const Meetings: React.FC = () => { const [audioUrl, setAudioUrl] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); + const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2); useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); + useEffect(() => { + if (!hasRunningTasks) return; + const timer = setInterval(() => fetchData(true), 5000); + return () => clearInterval(timer); + }, [hasRunningTasks, current, size, searchTitle, viewType]); - const fetchData = async () => { - setLoading(true); + const fetchData = async (silent = false) => { + if (!silent) setLoading(true); try { const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); if (res.data && res.data.data) { setData(res.data.data.records); setTotal(res.data.data.total); } - } catch (err) {} finally { setLoading(false); } + } catch (err) {} finally { if (!silent) setLoading(false); } }; const handleCreateSubmit = async () => { diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 8d0702b..99d39e4 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -1,4 +1,4 @@ -import Dashboard from "../pages/Dashboard"; +import { Dashboard } from "../pages/Dashboard"; import Users from "../pages/Users"; import Roles from "../pages/Roles"; import Permissions from "../pages/Permissions"; @@ -48,3 +48,4 @@ export const extraRoutes = [ { path: "/meetings/:id", element: , perm: "menu:meeting" }, { path: "/speaker-reg", label: "声纹注册", element: } ]; +