feat:导出pdf
parent
0ccf0aa87d
commit
61da050438
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -31,7 +51,7 @@ public class MeetingController {
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final String uploadPath;
|
private final String uploadPath;
|
||||||
|
|
||||||
public MeetingController(MeetingService meetingService,
|
public MeetingController(MeetingService meetingService,
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
@Value("${app.upload-path}") String uploadPath) {
|
@Value("${app.upload-path}") String uploadPath) {
|
||||||
this.meetingService = meetingService;
|
this.meetingService = meetingService;
|
||||||
|
|
@ -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,9 +97,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,12 +120,12 @@ public class MeetingController {
|
||||||
@RequestParam(defaultValue = "10") Integer size,
|
@RequestParam(defaultValue = "10") Integer size,
|
||||||
@RequestParam(required = false) String title,
|
@RequestParam(required = false) String title,
|
||||||
@RequestParam(defaultValue = "all") String viewType) {
|
@RequestParam(defaultValue = "all") String viewType) {
|
||||||
|
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||||
|
|
||||||
return ApiResponse.ok(meetingService.pageMeetings(current, size, title,
|
return ApiResponse.ok(meetingService.pageMeetings(current, size, title,
|
||||||
loginUser.getTenantId(), loginUser.getUserId(),
|
loginUser.getTenantId(), loginUser.getUserId(),
|
||||||
loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(),
|
loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(),
|
||||||
viewType, isAdmin));
|
viewType, isAdmin));
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -131,7 +191,7 @@ public class MeetingController {
|
||||||
String speakerId = params.get("speakerId").toString();
|
String speakerId = params.get("speakerId").toString();
|
||||||
String newName = params.get("newName") != null ? params.get("newName").toString() : null;
|
String newName = params.get("newName") != null ? params.get("newName").toString() : null;
|
||||||
String label = params.get("label") != null ? params.get("label").toString() : null;
|
String label = params.get("label") != null ? params.get("label").toString() : null;
|
||||||
|
|
||||||
meetingService.updateSpeakerInfo(meetingId, speakerId, newName, label);
|
meetingService.updateSpeakerInfo(meetingId, speakerId, newName, label);
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +202,7 @@ public class MeetingController {
|
||||||
Long meetingId = Long.valueOf(params.get("meetingId").toString());
|
Long meetingId = Long.valueOf(params.get("meetingId").toString());
|
||||||
Long summaryModelId = Long.valueOf(params.get("summaryModelId").toString());
|
Long summaryModelId = Long.valueOf(params.get("summaryModelId").toString());
|
||||||
Long promptId = Long.valueOf(params.get("promptId").toString());
|
Long promptId = Long.valueOf(params.get("promptId").toString());
|
||||||
|
|
||||||
meetingService.reSummary(meetingId, summaryModelId, promptId);
|
meetingService.reSummary(meetingId, summaryModelId, promptId);
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,49 @@ 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);
|
||||||
|
|
||||||
this.save(entity);
|
this.save(entity);
|
||||||
return toVO(entity);
|
return toVO(entity);
|
||||||
}
|
}
|
||||||
|
|
@ -40,11 +56,12 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
||||||
public AiModelVO updateModel(AiModelDTO dto) {
|
public AiModelVO updateModel(AiModelDTO dto) {
|
||||||
AiModel entity = this.getById(dto.getId());
|
AiModel entity = this.getById(dto.getId());
|
||||||
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);
|
||||||
|
|
||||||
this.updateById(entity);
|
this.updateById(entity);
|
||||||
return toVO(entity);
|
return toVO(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());
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}` } : {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,15 +50,15 @@ 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}
|
||||||
size="small"
|
size="small"
|
||||||
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
|
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
|
||||||
|
|
@ -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,9 +95,8 @@ 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 (
|
||||||
<div style={{ width: '100%', maxWidth: 450 }}>
|
<div style={{ width: '100%', maxWidth: 450 }}>
|
||||||
<Steps
|
<Steps
|
||||||
|
|
@ -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,74 +124,77 @@ 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
|
||||||
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||||
value={s.value || 0}
|
value={s.value || 0}
|
||||||
valueStyle={{ color: s.color, fontWeight: 700 }}
|
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 } })}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 核心任务流 - 垂直卡片列表 */}
|
<Card
|
||||||
<Card
|
|
||||||
title={
|
title={
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Space><ClockCircleOutlined /> 最近任务动态</Space>
|
<Space><ClockCircleOutlined /> 最近任务动态</Space>
|
||||||
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
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}`)}
|
||||||
|
|
@ -193,8 +203,7 @@ const Dashboard: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -143,25 +220,26 @@ const MeetingDetail: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [summaryForm] = Form.useForm();
|
const [summaryForm] = Form.useForm();
|
||||||
|
|
||||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue