feat:导出pdf

dev_na
chenhao 2026-03-05 17:52:08 +08:00
parent 0ccf0aa87d
commit 61da050438
12 changed files with 1141 additions and 178 deletions

View File

@ -94,6 +94,16 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -2,26 +2,46 @@ package com.imeeting.controller.biz;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult; import com.imeeting.common.PageResult;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.MeetingDTO; import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser; import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.common.RedisKeys; import org.apache.fontbox.ttf.TrueTypeCollection;
import org.springframework.data.redis.core.StringRedisTemplate; 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.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.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/biz/meeting") @RequestMapping("/api/biz/meeting")
@ -46,14 +66,12 @@ public class MeetingController {
String json = redisTemplate.opsForValue().get(key); String json = redisTemplate.opsForValue().get(key);
if (json != null) { if (json != null) {
try { try {
// 直接返回 Redis 中的进度 JSON
return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class)); return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class));
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("解析进度异常"); return ApiResponse.error("解析进度异常");
} }
} }
// 如果 Redis 没数据,根据数据库状态返回
Meeting m = meetingService.getById(id); Meeting m = meetingService.getById(id);
Map<String, Object> fallback = new HashMap<>(); Map<String, Object> fallback = new HashMap<>();
if (m != null) { if (m != null) {
@ -79,7 +97,7 @@ public class MeetingController {
File dir = new File(uploadDir); File dir = new File(uploadDir);
if (!dir.exists()) dir.mkdirs(); if (!dir.exists()) dir.mkdirs();
String fileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
file.transferTo(new File(uploadDir + fileName)); file.transferTo(new File(uploadDir + fileName));
return ApiResponse.ok("/api/static/audio/" + fileName); return ApiResponse.ok("/api/static/audio/" + fileName);
@ -118,6 +136,48 @@ public class MeetingController {
return ApiResponse.ok(meetingService.getDetail(id)); return ApiResponse.ok(meetingService.getDetail(id));
} }
@GetMapping("/{id}/summary/export")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> 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}") @GetMapping("/transcripts/{id}")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) { public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
@ -154,12 +214,12 @@ public class MeetingController {
Meeting existing = meetingService.getById(meeting.getId()); Meeting existing = meetingService.getById(meeting.getId());
if (existing == null) return ApiResponse.error("会议不存在"); if (existing == null) return ApiResponse.error("会议不存在");
// 权限校验:仅发起人或管理员可修改 if (!existing.getCreatorId().equals(loginUser.getUserId())
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
&& !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权修改此会议信息"); return ApiResponse.error("无权修改此会议信息");
} }
// 仅允许修改标题、人员、标签等基本信息
return ApiResponse.ok(meetingService.updateById(meeting)); return ApiResponse.ok(meetingService.updateById(meeting));
} }
@ -170,11 +230,360 @@ public class MeetingController {
Meeting existing = meetingService.getById(id); Meeting existing = meetingService.getById(id);
if (existing == null) return ApiResponse.ok(true); 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("无权删除此会议"); return ApiResponse.error("无权删除此会议");
} }
meetingService.deleteMeeting(id); meetingService.deleteMeeting(id);
return ApiResponse.ok(true); 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<PDFont> 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<PDFont> 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<String> wrapByWidth(String text, float maxWidth, List<PDFont> fonts, float fontSize) throws IOException {
String content = text == null ? "" : text;
if (content.isEmpty()) return List.of("");
List<String> 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<PDFont> loadPdfFonts(PDDocument document) {
List<PDFont> 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<MdBlock> parseMarkdownBlocks(String markdown) {
List<MdBlock> 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<MdBlock> 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<PDFont> 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<PDFont> 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;
}
}
} }

View File

@ -9,6 +9,7 @@ public class MeetingVO {
private Long id; private Long id;
private Long tenantId; private Long tenantId;
private Long creatorId; private Long creatorId;
private String creatorName;
private String title; private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

View File

@ -4,30 +4,46 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.PageResult; import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiModel; import com.imeeting.entity.biz.AiModel;
import com.imeeting.mapper.biz.AiModelMapper; import com.imeeting.mapper.biz.AiModelMapper;
import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiModelService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor
public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> implements AiModelService { public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> implements AiModelService {
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public AiModelVO saveModel(AiModelDTO dto) { public AiModelVO saveModel(AiModelDTO dto) {
AiModel entity = new AiModel(); AiModel entity = new AiModel();
copyProperties(dto, entity); copyProperties(dto, entity);
pushAsrConfig(entity);
handleAsrWsUrl(entity); handleAsrWsUrl(entity);
handleDefaultLogic(entity); handleDefaultLogic(entity);
@ -42,6 +58,7 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
if (entity == null) throw new RuntimeException("Model not found"); if (entity == null) throw new RuntimeException("Model not found");
copyProperties(dto, entity); copyProperties(dto, entity);
pushAsrConfig(entity);
handleAsrWsUrl(entity); handleAsrWsUrl(entity);
handleDefaultLogic(entity); handleDefaultLogic(entity);
@ -167,6 +184,43 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> 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<String, String> 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<String> 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) { private void copyProperties(AiModelDTO dto, AiModel entity) {
entity.setModelType(dto.getModelType()); entity.setModelType(dto.getModelType());
entity.setModelName(dto.getModelName()); entity.setModelName(dto.getModelName());

View File

@ -212,6 +212,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
vo.setId(meeting.getId()); vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId()); vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId()); vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setTitle(meeting.getTitle()); vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime()); vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags()); vo.setTags(meeting.getTags());

View File

@ -11,8 +11,10 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"antd": "^5.13.2", "antd": "^5.13.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"html2canvas": "^1.4.1",
"i18next": "^25.8.6", "i18next": "^25.8.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
@ -1506,12 +1508,25 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT" "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": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz",
@ -1532,6 +1547,13 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
@ -1677,6 +1699,15 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -1755,6 +1786,26 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz",
@ -1864,6 +1915,27 @@
"toggle-selection": "^1.0.6" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -1937,6 +2009,16 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2068,6 +2150,23 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "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": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
@ -2285,6 +2384,19 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/i18next": {
"version": "25.8.6", "version": "25.8.6",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.6.tgz", "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.6.tgz",
@ -2331,6 +2443,12 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT" "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": { "node_modules/is-alphabetical": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@ -2434,6 +2552,23 @@
"node": ">=6" "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": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz",
@ -3123,6 +3258,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz",
@ -3148,6 +3289,13 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -3200,6 +3348,16 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "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": { "node_modules/rc-cascader": {
"version": "3.34.0", "version": "3.34.0",
"resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz",
@ -3933,6 +4091,13 @@
"react-dom": ">=16.8" "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": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz",
@ -3972,6 +4137,16 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT" "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": { "node_modules/rollup": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz",
@ -4065,6 +4240,16 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/string-convert": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
@ -4109,6 +4294,25 @@
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT" "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": { "node_modules/throttle-debounce": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", "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" "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz",

View File

@ -12,8 +12,10 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"antd": "^5.13.2", "antd": "^5.13.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"html2canvas": "^1.4.1",
"i18next": "^25.8.6", "i18next": "^25.8.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",

View File

@ -1,9 +1,11 @@
import http from "../http"; import http from "../http";
import axios from "axios";
export interface MeetingVO { export interface MeetingVO {
id: number; id: number;
tenantId: number; tenantId: number;
creatorId: number; creatorId: number;
creatorName?: string;
title: string; title: string;
meetingTime: string; meetingTime: string;
participants: string; participants: string;
@ -114,3 +116,12 @@ export const getMeetingProgress = (id: number) => {
`/api/biz/meeting/${id}/progress` `/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}` } : {}
});
};

View File

@ -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 { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import { import {
HistoryOutlined, CheckCircleOutlined, LoadingOutlined, HistoryOutlined,
AudioOutlined, RobotOutlined, CheckCircleOutlined,
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined, LoadingOutlined,
PlayCircleOutlined, FileTextOutlined AudioOutlined,
RobotOutlined,
CalendarOutlined,
TeamOutlined,
RiseOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
FileTextOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -13,7 +20,6 @@ import { MeetingVO, getMeetingProgress, MeetingProgress } from '../api/business/
const { Title, Text } = Typography; const { Title, Text } = Typography;
// 新增进度显示子组件
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null); const [progress, setProgress] = useState<MeetingProgress | null>(null);
@ -23,10 +29,12 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
const fetchProgress = async () => { const fetchProgress = async () => {
try { try {
const res = await getMeetingProgress(meeting.id); const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) { if (res.data?.data) {
setProgress(res.data.data); setProgress(res.data.data);
} }
} catch (err) {} } catch (err) {
// ignore
}
}; };
fetchProgress(); fetchProgress();
@ -42,11 +50,11 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
return ( return (
<div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}> <div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text size="small" type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} /> <LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'} {progress?.message || '准备分析中...'}
</Text> </Text>
{!isError && <Text size="small" strong style={{ color: '#1890ff' }}>{percent}%</Text>} {!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
</div> </div>
<Progress <Progress
percent={isError ? 100 : percent} percent={isError ? 100 : percent}
@ -59,24 +67,24 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
); );
}; };
const Dashboard: React.FC = () => { export const Dashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]); const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const processingCount = Number(stats?.processingTasks || 0);
const dashboardLoading = loading && processingCount > 0;
useEffect(() => { useEffect(() => {
fetchDashboardData(); fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时 const timer = setInterval(fetchDashboardData, 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
const [statsRes, tasksRes] = await Promise.all([ const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
getDashboardStats(),
getRecentTasks()
]);
setStats(statsRes.data.data); setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []); setRecentTasks(tasksRes.data.data || []);
} catch (err) { } catch (err) {
@ -87,7 +95,6 @@ const Dashboard: React.FC = () => {
}; };
const renderTaskProgress = (item: MeetingVO) => { const renderTaskProgress = (item: MeetingVO) => {
// 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status); const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return ( return (
@ -100,12 +107,12 @@ const Dashboard: React.FC = () => {
{ {
title: '语音转录', title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />, icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转中' : '排队中') description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转中' : '排队中')
}, },
{ {
title: '智能总结', title: '智能总结',
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />, icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在构思' : '待触发') description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
}, },
{ {
title: '分析完成', title: '分析完成',
@ -117,18 +124,23 @@ const Dashboard: React.FC = () => {
); );
}; };
const statCards = [
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
{
label: '当前分析中任务',
value: stats?.processingTasks,
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
color: '#faad14'
},
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
];
return ( return (
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}> <div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* 顶部统计区 */}
<Row gutter={24} style={{ marginBottom: 24 }}> <Row gutter={24} style={{ marginBottom: 24 }}>
{[ {statCards.map((s, idx) => (
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
{ label: '当前分析中任务', value: stats?.processingTasks, icon: <LoadingOutlined />, color: '#faad14' },
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
].map((s, idx) => (
<Col span={6} key={idx}> <Col span={6} key={idx}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}> <Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
<Statistic <Statistic
@ -142,7 +154,6 @@ const Dashboard: React.FC = () => {
))} ))}
</Row> </Row>
{/* 核心任务流 - 垂直卡片列表 */}
<Card <Card
title={ title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -154,37 +165,36 @@ const Dashboard: React.FC = () => {
style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }} style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}
> >
<List <List
loading={loading} loading={dashboardLoading}
dataSource={recentTasks} dataSource={recentTasks}
renderItem={(item) => ( renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}> <List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<Row gutter={32} align="middle"> <Row gutter={32} align="middle">
{/* 左:会议基础信息 */}
<Col span={8}> <Col span={8}>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}> <Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title} {item.title}
</Title> </Title>
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}> <Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Text type="secondary" size="small"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text> <Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary" size="small"><TeamOutlined /> {item.participants || '系统记录'}</Text> <Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
</Space> </Space>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
{item.tags?.split(',').filter(Boolean).map(t => <Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag>)} {item.tags?.split(',').filter(Boolean).map((t) => (
<Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
))}
</div> </div>
</Space> </Space>
</Col> </Col>
{/* 中:线性进度条 */}
<Col span={12}> <Col span={12}>
{renderTaskProgress(item)} {renderTaskProgress(item)}
</Col> </Col>
{/* 右:操作入口 */}
<Col span={4} style={{ textAlign: 'right' }}> <Col span={4} style={{ textAlign: 'right' }}>
<Button <Button
type={item.status === 3 ? "primary" : "default"} type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3} ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />} icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)} onClick={() => navigate(`/meetings/${item.id}`)}
@ -194,7 +204,6 @@ const Dashboard: React.FC = () => {
</Col> </Col>
</Row> </Row>
{/* 进度实时展示 */}
<MeetingProgressDisplay meeting={item} /> <MeetingProgressDisplay meeting={item} />
</div> </div>
</List.Item> </List.Item>
@ -203,6 +212,7 @@ const Dashboard: React.FC = () => {
/> />
</Card> </Card>
</div> </div>
<style>{` <style>{`
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; } .ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; } .ant-steps-item-description { font-size: 11px !important; }

View File

@ -1,10 +1,55 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Card, Row, Col, Typography, Tag, Space, Divider, Button, Skeleton, Empty, List, Avatar, Breadcrumb, Popover, Input, Select, message, Drawer, Form, Modal, Progress } from 'antd'; import {
import { LeftOutlined, UserOutlined, ClockCircleOutlined, AudioOutlined, RobotOutlined, LoadingOutlined, EditOutlined, SyncOutlined, SettingOutlined } from '@ant-design/icons'; Card,
Row,
Col,
Typography,
Tag,
Space,
Divider,
Button,
Skeleton,
Empty,
List,
Avatar,
Breadcrumb,
Popover,
Input,
Select,
message,
Drawer,
Form,
Modal,
Progress,
} from 'antd';
import {
LeftOutlined,
UserOutlined,
ClockCircleOutlined,
AudioOutlined,
RobotOutlined,
LoadingOutlined,
EditOutlined,
SyncOutlined,
DownloadOutlined,
} from '@ant-design/icons';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO, getMeetingProgress, MeetingProgress } from '../../api/business/meeting'; import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import {
getMeetingDetail,
getTranscripts,
updateSpeakerInfo,
reSummary,
updateMeeting,
MeetingVO,
MeetingTranscriptVO,
getMeetingProgress,
MeetingProgress,
downloadMeetingSummary,
} from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { useDict } from '../../hooks/useDict'; import { useDict } from '../../hooks/useDict';
@ -14,7 +59,6 @@ import { SysUser } from '../../types';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
// 详情页进度显示组件
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => { const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null); const [progress, setProgress] = useState<MeetingProgress | null>(null);
@ -22,24 +66,25 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
const fetchProgress = async () => { const fetchProgress = async () => {
try { try {
const res = await getMeetingProgress(meetingId); const res = await getMeetingProgress(meetingId);
if (res.data && res.data.data) { if (res.data?.data) {
setProgress(res.data.data); setProgress(res.data.data);
if (res.data.data.percent === 100) { if (res.data.data.percent === 100) {
onComplete(); onComplete();
} }
} }
} catch (err) {} } catch (err) {
// ignore polling errors
}
}; };
fetchProgress(); fetchProgress();
const timer = setInterval(fetchProgress, 3000); const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [meetingId]); }, [meetingId, onComplete]);
const percent = progress?.percent || 0; const percent = progress?.percent || 0;
const isError = percent < 0; const isError = percent < 0;
// 格式化剩余时间 (ETA)
const formatETA = (seconds?: number) => { const formatETA = (seconds?: number) => {
if (!seconds || seconds <= 0) return '正在分析中'; if (!seconds || seconds <= 0) return '正在分析中';
if (seconds < 60) return `${seconds}`; if (seconds < 60) return `${seconds}`;
@ -49,44 +94,57 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
}; };
return ( return (
<div style={{ <div
height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', style={{
background: '#fff', borderRadius: 16, padding: 40 height: '100%',
}}> display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}> <div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 24 }}>AI </Title> <Title level={3} style={{ marginBottom: 24 }}>AI </Title>
<Progress <Progress
type="circle" type="circle"
percent={isError ? 100 : percent} percent={isError ? 100 : percent}
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')} status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }} strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
width={180} width={180}
strokeWidth={8} strokeWidth={8}
/> />
<div style={{ marginTop: 32 }}> <div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#1890ff', display: 'block', marginBottom: 8 }}> <Text
strong
style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#1890ff', display: 'block', marginBottom: 8 }}
>
{progress?.message || '正在准备计算资源...'} {progress?.message || '正在准备计算资源...'}
</Text> </Text>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</div> </div>
<Divider style={{ margin: '32px 0' }} /> <Divider style={{ margin: '32px 0' }} />
<Row gutter={24}> <Row gutter={24}>
<Col span={8}> <Col span={8}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title> <Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
</Space> </Space>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? '--' : formatETA(progress?.eta)}</Title> <Title level={4} style={{ margin: 0 }}>{isError ? '--' : formatETA(progress?.eta)}</Title>
</Space> </Space>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>{isError ? '已中断' : '正常'}</Title> <Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '已中断' : '正常'}
</Title>
</Space> </Space>
</Col> </Col>
</Row> </Row>
@ -122,18 +180,37 @@ const SpeakerEditor: React.FC<{
}; };
return ( return (
<div style={{ width: 250, padding: '8px 4px' }} onClick={e => e.stopPropagation()}> <div style={{ width: 250, padding: '8px 4px' }} onClick={(e) => e.stopPropagation()}>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} /> <Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入姓名"
size="small"
style={{ marginTop: 4 }}
/>
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear> <Select
{speakerLabels.map(item => <Select.Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Select.Option>)} value={label}
onChange={setLabel}
placeholder="选择角色"
style={{ width: '100%', marginTop: 4 }}
size="small"
allowClear
>
{speakerLabels.map((item) => (
<Select.Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Select.Option>
))}
</Select> </Select>
</div> </div>
<Button type="primary" size="small" block onClick={handleSave} loading={loading}></Button> <Button type="primary" size="small" block onClick={handleSave} loading={loading}>
</Button>
</div> </div>
); );
}; };
@ -150,18 +227,19 @@ const MeetingDetail: React.FC = () => {
const [editVisible, setEditVisible] = useState(false); const [editVisible, setEditVisible] = useState(false);
const [summaryVisible, setSummaryVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]); const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]); const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]); const [, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label'); const { items: speakerLabels } = useDict('biz_speaker_label');
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const summaryPdfRef = useRef<HTMLDivElement>(null);
// 核心权限判断
const isOwner = React.useMemo(() => { const isOwner = React.useMemo(() => {
if (!meeting) return false; if (!meeting) return false;
const profileStr = sessionStorage.getItem("userProfile"); const profileStr = sessionStorage.getItem('userProfile');
if (profileStr) { if (profileStr) {
const profile = JSON.parse(profileStr); const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId; return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
@ -179,10 +257,7 @@ const MeetingDetail: React.FC = () => {
const fetchData = async (meetingId: number) => { const fetchData = async (meetingId: number) => {
try { try {
const [detailRes, transcriptRes] = await Promise.all([ const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
getMeetingDetail(meetingId),
getTranscripts(meetingId)
]);
setMeeting(detailRes.data.data); setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []); setTranscripts(transcriptRes.data.data || []);
} catch (err) { } catch (err) {
@ -197,29 +272,30 @@ const MeetingDetail: React.FC = () => {
const [mRes, pRes, dRes] = await Promise.all([ const [mRes, pRes, dRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'LLM' }), getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
getPromptPage({ current: 1, size: 100 }), getPromptPage({ current: 1, size: 100 }),
getAiModelDefault('LLM') getAiModelDefault('LLM'),
]); ]);
setLlmModels(mRes.data.data.records.filter(m => m.status === 1)); setLlmModels(mRes.data.data.records.filter((m) => m.status === 1));
setPrompts(pRes.data.data.records.filter(p => p.status === 1)); setPrompts(pRes.data.data.records.filter((p) => p.status === 1));
summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id }); summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id });
} catch (e) {} } catch (e) {
// ignore
}
}; };
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const users = await listUsers(); const users = await listUsers();
setUserList(users || []); setUserList(users || []);
} catch (err) {} } catch (err) {
// ignore
}
}; };
const handleEditMeeting = () => { const handleEditMeeting = () => {
if (!meeting || !isOwner) return; if (!meeting || !isOwner) return;
// 由于后端存储的是姓名字符串,而我们现在需要 ID 匹配,
// 这里简单处理:让发起人依然可以修改基础元数据。
// 如果需要修改参会人 ID需要前端存储 ID 列表快照。
form.setFieldsValue({ form.setFieldsValue({
...meeting, ...meeting,
tags: meeting.tags?.split(',').filter(Boolean) tags: meeting.tags?.split(',').filter(Boolean),
}); });
setEditVisible(true); setEditVisible(true);
}; };
@ -231,7 +307,7 @@ const MeetingDetail: React.FC = () => {
await updateMeeting({ await updateMeeting({
...vals, ...vals,
id: meeting?.id, id: meeting?.id,
tags: vals.tags?.join(',') tags: vals.tags?.join(','),
}); });
message.success('会议信息已更新'); message.success('会议信息已更新');
setEditVisible(false); setEditVisible(false);
@ -250,7 +326,7 @@ const MeetingDetail: React.FC = () => {
await reSummary({ await reSummary({
meetingId: Number(id), meetingId: Number(id),
summaryModelId: vals.summaryModelId, summaryModelId: vals.summaryModelId,
promptId: vals.promptId promptId: vals.promptId,
}); });
message.success('已重新发起总结任务'); message.success('已重新发起总结任务');
setSummaryVisible(false); setSummaryVisible(false);
@ -276,6 +352,105 @@ const MeetingDetail: React.FC = () => {
} }
}; };
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);
return normalMatch?.[1] || fallback || 'summary';
};
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
if (!meeting) return;
if (!meeting.summaryContent) {
message.warning('当前暂无可下载的AI总结');
return;
}
if (format === 'pdf') {
try {
setDownloadLoading('pdf');
if (!summaryPdfRef.current) {
message.error('未找到可导出的总结内容');
return;
}
const canvas = await html2canvas(summaryPdfRef.current, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pageWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
const fileName = `${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI-summary.pdf`;
pdf.save(fileName);
} catch (err) {
console.error(err);
message.error('PDF导出失败');
} finally {
setDownloadLoading(null);
}
return;
}
try {
setDownloadLoading('word');
const res = await downloadMeetingSummary(meeting.id, format);
const contentType: string =
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 {
const json = JSON.parse(text);
message.error(json?.msg || '下载失败');
} catch {
message.error('下载失败');
}
return;
}
const blob = new Blob([res.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFileNameFromDisposition(
res.headers['content-disposition'],
`meeting-summary.${format === 'pdf' ? 'pdf' : 'docx'}`,
);
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error(err);
message.error('下载失败');
} finally {
setDownloadLoading(null);
}
};
if (loading) return <div style={{ padding: '24px' }}><Skeleton active /></div>; if (loading) return <div style={{ padding: '24px' }}><Skeleton active /></div>;
if (!meeting) return <div style={{ padding: '24px' }}><Empty description="会议不存在" /></div>; if (!meeting) return <div style={{ padding: '24px' }}><Empty description="会议不存在" /></div>;
@ -291,13 +466,17 @@ const MeetingDetail: React.FC = () => {
<Col> <Col>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Title level={4} style={{ margin: 0 }}> <Title level={4} style={{ margin: 0 }}>
{meeting.title} {isOwner && <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff' }} onClick={handleEditMeeting} />} {meeting.title}
{isOwner && (
<EditOutlined
style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }}
onClick={handleEditMeeting}
/>
)}
</Title> </Title>
<Space split={<Divider type="vertical" />}> <Space split={<Divider type="vertical" />}>
<Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text> <Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text>
<Space> <Space>{meeting.tags?.split(',').filter(Boolean).map((t) => <Tag key={t} color="blue">{t}</Tag>)}</Space>
{meeting.tags?.split(',').filter(Boolean).map(t => <Tag key={t} color="blue">{t}</Tag>)}
</Space>
<Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text> <Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text>
</Space> </Space>
</Space> </Space>
@ -305,27 +484,25 @@ const MeetingDetail: React.FC = () => {
<Col> <Col>
<Space> <Space>
{isOwner && meeting.status === 3 && ( {isOwner && meeting.status === 3 && (
<Button <Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
icon={<SyncOutlined />}
type="primary"
ghost
onClick={() => setSummaryVisible(true)}
disabled={actionLoading}
>
</Button> </Button>
)} )}
{isOwner && meeting.status === 2 && ( {isOwner && meeting.status === 2 && (
<Button <Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
icon={<LoadingOutlined />}
type="primary"
ghost
disabled
loading
>
</Button> </Button>
)} )}
{meeting.status === 3 && !!meeting.summaryContent && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
PDF
</Button>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
Word
</Button>
</>
)}
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}></Button> <Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}></Button>
</Space> </Space>
</Col> </Col>
@ -333,73 +510,141 @@ const MeetingDetail: React.FC = () => {
</Card> </Card>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
{(meeting.status === 1 || meeting.status === 2) ? ( {meeting.status === 1 || meeting.status === 2 ? (
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} /> <MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
) : ( ) : (
<Row gutter={24} style={{ height: '100%' }}> <Row gutter={24} style={{ height: '100%' }}>
<Col span={12} style={{ height: '100%' }}> <Col span={12} style={{ height: '100%' }}>
<Card title={<span><AudioOutlined /> </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} <Card
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}> title={<span><AudioOutlined /> </span>}
<List dataSource={transcripts} renderItem={(item) => ( style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}> bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }}
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />} extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}
title={<Space> >
{isOwner ? ( <List
<Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click"> dataSource={transcripts}
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span> renderItem={(item) => (
</Popover> <List.Item
) : ( style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }}
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text> onClick={() => seekTo(item.startTime)}
)} >
{item.speakerLabel && <Tag color="blue">{speakerLabels.find(l => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}</Tag>} <List.Item.Meta
<Text type="secondary" size="small" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text> avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} /> title={
</List.Item> <Space>
)} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} /> {isOwner ? (
<Popover
content={
<SpeakerEditor
meetingId={meeting.id}
speakerId={item.speakerId}
initialName={item.speakerName}
initialLabel={item.speakerLabel}
onSuccess={() => fetchData(meeting.id)}
/>
}
title="编辑发言人"
trigger="click"
>
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={(e) => e.stopPropagation()}>
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} />
</span>
</Popover>
) : (
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
)}
{item.speakerLabel && (
<Tag color="blue">
{speakerLabels.find((l) => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}
</Tag>
)}
<Text type="secondary" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text>
</Space>
}
description={<Text style={{ color: '#333' }}>{item.content}</Text>}
/>
</List.Item>
)}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
</Card> </Card>
</Col> </Col>
<Col span={12} style={{ height: '100%' }}> <Col span={12} style={{ height: '100%' }}>
<Card title={<span><RobotOutlined /> AI </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}> <Card
{meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> : title={<span><RobotOutlined /> AI </span>}
<div style={{ textAlign: 'center', marginTop: '100px' }}>{meeting.status === 2 ? <Space direction="vertical"><LoadingOutlined style={{ fontSize: 24 }} spin /><Text type="secondary">...</Text></Space> : <Empty description="暂无总结" />}</div>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}
>
<div ref={summaryPdfRef}>
{meeting.summaryContent ? (
<div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div>
) : (
<div style={{ textAlign: 'center', marginTop: '100px' }}>
{meeting.status === 2 ? (
<Space direction="vertical">
<LoadingOutlined style={{ fontSize: 24 }} spin />
<Text type="secondary">...</Text>
</Space>
) : (
<Empty description="暂无总结" />
)}
</div>
)}
</div>
</Card> </Card>
</Col> </Col>
</Row> </Row>
)} )}
</div> </div>
<style>{` <style>{`
.markdown-body { font-size: 14px; line-height: 1.8; color: #333; } .markdown-body { font-size: 14px; line-height: 1.8; color: #333; }
.markdown-body p { margin-bottom: 16px; } .markdown-body p { margin-bottom: 16px; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; } .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; }
`}</style> `}</style>
{/* 修改基础信息弹窗 - 仅限 Owner */}
{isOwner && ( {isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600}> <Modal
title="编辑会议信息"
open={editVisible}
onOk={handleUpdateBasic}
onCancel={() => setEditVisible(false)}
confirmLoading={actionLoading}
width={600}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="tags" label="业务标签"><Select mode="tags" placeholder="输入标签按回车" /></Form.Item> <Form.Item name="tags" label="业务标签"><Select mode="tags" placeholder="输入标签按回车" /></Form.Item>
<Text type="warning" size="small"> ID </Text> <Text type="warning"> ID </Text>
</Form> </Form>
</Modal> </Modal>
)} )}
{/* 重新总结抽屉 - 仅限 Owner */}
{isOwner && ( {isOwner && (
<Drawer title="重新生成 AI 总结" width={400} onClose={() => setSummaryVisible(false)} open={summaryVisible} extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}> <Drawer
title="重新生成 AI 总结"
width={400}
onClose={() => setSummaryVisible(false)}
open={summaryVisible}
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}
>
<Form form={summaryForm} layout="vertical"> <Form form={summaryForm} layout="vertical">
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}> <Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择 LLM 模型"> <Select placeholder="选择 LLM 模型">
{llmModels.map(m => <Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}></Tag>}</Option>)} {llmModels.map((m) => (
<Option key={m.id} value={m.id}>
{m.modelName} {m.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}></Tag>}
</Option>
))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}> <Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
<Select placeholder="选择新模板"> <Select placeholder="选择模板">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)} {prompts.map((p) => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select> </Select>
</Form.Item> </Form.Item>
<Divider /> <Divider />
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
</Form> </Form>
</Drawer> </Drawer>
)} )}

View File

@ -353,15 +353,21 @@ const Meetings: React.FC = () => {
const [audioUrl, setAudioUrl] = useState(''); const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]); const [fileList, setFileList] = useState<any[]>([]);
const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2);
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); 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 () => { const fetchData = async (silent = false) => {
setLoading(true); if (!silent) setLoading(true);
try { try {
const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); 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); } 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 () => { const handleCreateSubmit = async () => {

View File

@ -1,4 +1,4 @@
import Dashboard from "../pages/Dashboard"; import { Dashboard } from "../pages/Dashboard";
import Users from "../pages/Users"; import Users from "../pages/Users";
import Roles from "../pages/Roles"; import Roles from "../pages/Roles";
import Permissions from "../pages/Permissions"; import Permissions from "../pages/Permissions";
@ -48,3 +48,4 @@ export const extraRoutes = [
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" }, { path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg /> } { path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg /> }
]; ];