feat:导出pdf
parent
0ccf0aa87d
commit
61da050438
|
|
@ -94,6 +94,16 @@
|
|||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -2,26 +2,46 @@ package com.imeeting.controller.biz;
|
|||
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.common.PageResult;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.dto.biz.MeetingDTO;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.security.LoginUser;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.apache.fontbox.ttf.TrueTypeCollection;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/biz/meeting")
|
||||
|
|
@ -46,14 +66,12 @@ public class MeetingController {
|
|||
String json = redisTemplate.opsForValue().get(key);
|
||||
if (json != null) {
|
||||
try {
|
||||
// 直接返回 Redis 中的进度 JSON
|
||||
return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class));
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("解析进度异常");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 Redis 没数据,根据数据库状态返回
|
||||
Meeting m = meetingService.getById(id);
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
if (m != null) {
|
||||
|
|
@ -79,7 +97,7 @@ public class MeetingController {
|
|||
File dir = new File(uploadDir);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
String fileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
|
||||
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
|
||||
file.transferTo(new File(uploadDir + fileName));
|
||||
|
||||
return ApiResponse.ok("/api/static/audio/" + fileName);
|
||||
|
|
@ -118,6 +136,48 @@ public class MeetingController {
|
|||
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}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
|
||||
|
|
@ -154,12 +214,12 @@ public class MeetingController {
|
|||
Meeting existing = meetingService.getById(meeting.getId());
|
||||
if (existing == null) return ApiResponse.error("会议不存在");
|
||||
|
||||
// 权限校验:仅发起人或管理员可修改
|
||||
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||
if (!existing.getCreatorId().equals(loginUser.getUserId())
|
||||
&& !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
|
||||
&& !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||
return ApiResponse.error("无权修改此会议信息");
|
||||
}
|
||||
|
||||
// 仅允许修改标题、人员、标签等基本信息
|
||||
return ApiResponse.ok(meetingService.updateById(meeting));
|
||||
}
|
||||
|
||||
|
|
@ -170,11 +230,360 @@ public class MeetingController {
|
|||
Meeting existing = meetingService.getById(id);
|
||||
if (existing == null) return ApiResponse.ok(true);
|
||||
|
||||
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||
if (!existing.getCreatorId().equals(loginUser.getUserId())
|
||||
&& !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
|
||||
&& !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||
return ApiResponse.error("无权删除此会议");
|
||||
}
|
||||
|
||||
meetingService.deleteMeeting(id);
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
private byte[] buildWordBytes(MeetingVO meeting) throws IOException {
|
||||
try (XWPFDocument document = new XWPFDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
XWPFParagraph title = document.createParagraph();
|
||||
XWPFRun titleRun = title.createRun();
|
||||
titleRun.setBold(true);
|
||||
titleRun.setFontSize(16);
|
||||
titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI 总结");
|
||||
|
||||
XWPFParagraph timeP = document.createParagraph();
|
||||
timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime()));
|
||||
|
||||
XWPFParagraph participantsP = document.createParagraph();
|
||||
participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants()));
|
||||
|
||||
document.createParagraph();
|
||||
|
||||
for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) {
|
||||
XWPFParagraph p = document.createParagraph();
|
||||
if (block.type == MdType.HEADING) {
|
||||
int size = Math.max(12, 18 - (block.level - 1) * 2);
|
||||
appendMarkdownRuns(p, block.text, true, size);
|
||||
} else if (block.type == MdType.LIST) {
|
||||
p.setIndentationLeft(360);
|
||||
XWPFRun bullet = p.createRun();
|
||||
bullet.setFontSize(12);
|
||||
bullet.setText("- ");
|
||||
appendMarkdownRuns(p, block.text, false, 12);
|
||||
} else {
|
||||
appendMarkdownRuns(p, block.text, false, 12);
|
||||
}
|
||||
}
|
||||
|
||||
document.write(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
List<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 tenantId;
|
||||
private Long creatorId;
|
||||
private String creatorName;
|
||||
private String title;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
|
|
|||
|
|
@ -4,30 +4,46 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.PageResult;
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.AiModel;
|
||||
import com.imeeting.mapper.biz.AiModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> implements AiModelService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AiModelVO saveModel(AiModelDTO dto) {
|
||||
AiModel entity = new AiModel();
|
||||
copyProperties(dto, entity);
|
||||
|
||||
pushAsrConfig(entity);
|
||||
handleAsrWsUrl(entity);
|
||||
handleDefaultLogic(entity);
|
||||
|
||||
|
|
@ -42,6 +58,7 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
|||
if (entity == null) throw new RuntimeException("Model not found");
|
||||
|
||||
copyProperties(dto, entity);
|
||||
pushAsrConfig(entity);
|
||||
handleAsrWsUrl(entity);
|
||||
handleDefaultLogic(entity);
|
||||
|
||||
|
|
@ -167,6 +184,43 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
|||
}
|
||||
}
|
||||
|
||||
private void pushAsrConfig(AiModel entity) {
|
||||
if (!"ASR".equals(entity.getModelType())) {
|
||||
return;
|
||||
}
|
||||
if (entity.getBaseUrl() == null || entity.getBaseUrl().trim().isEmpty()) {
|
||||
throw new RuntimeException("baseUrl is required for ASR model");
|
||||
}
|
||||
if (entity.getModelCode() == null || entity.getModelCode().trim().isEmpty()) {
|
||||
throw new RuntimeException("modelCode is required for ASR model");
|
||||
}
|
||||
|
||||
String targetUrl = entity.getBaseUrl().endsWith("/")
|
||||
? entity.getBaseUrl() + "api/asrconfig"
|
||||
: entity.getBaseUrl() + "/api/asrconfig";
|
||||
|
||||
try {
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("asr_model_type", entity.getModelCode());
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(targetUrl))
|
||||
.timeout(Duration.ofSeconds(100))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
log.error("Push ASR config failed, url={}, code={}, body={}", targetUrl, response.statusCode(), response.body());
|
||||
throw new RuntimeException("Third-party ASR config save failed: HTTP " + response.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Push ASR config error, url={}, msg={}", targetUrl, e.getMessage(), e);
|
||||
throw new RuntimeException("Third-party ASR config save failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyProperties(AiModelDTO dto, AiModel entity) {
|
||||
entity.setModelType(dto.getModelType());
|
||||
entity.setModelName(dto.getModelName());
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
|||
vo.setId(meeting.getId());
|
||||
vo.setTenantId(meeting.getTenantId());
|
||||
vo.setCreatorId(meeting.getCreatorId());
|
||||
vo.setCreatorName(meeting.getCreatorName());
|
||||
vo.setTitle(meeting.getTitle());
|
||||
vo.setMeetingTime(meeting.getMeetingTime());
|
||||
vo.setTags(meeting.getTags());
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^5.13.2",
|
||||
"axios": "^1.6.7",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.8.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
|
|
@ -1506,12 +1508,25 @@
|
|||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz",
|
||||
|
|
@ -1532,6 +1547,13 @@
|
|||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
|
||||
|
|
@ -1677,6 +1699,15 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
|
|
@ -1755,6 +1786,26 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz",
|
||||
|
|
@ -1864,6 +1915,27 @@
|
|||
"toggle-selection": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz",
|
||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
|
|
@ -1937,6 +2009,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -2068,6 +2150,23 @@
|
|||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
|
|
@ -2285,6 +2384,19 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.6",
|
||||
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.6.tgz",
|
||||
|
|
@ -2331,6 +2443,12 @@
|
|||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
|
|
@ -2434,6 +2552,23 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.0.tgz",
|
||||
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
|
|
@ -3123,6 +3258,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
|
|
@ -3148,6 +3289,13 @@
|
|||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -3200,6 +3348,16 @@
|
|||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc-cascader": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz",
|
||||
|
|
@ -3933,6 +4091,13 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
|
|
@ -3972,6 +4137,16 @@
|
|||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz",
|
||||
|
|
@ -4065,6 +4240,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
|
||||
|
|
@ -4109,6 +4294,25 @@
|
|||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
|
|
@ -4285,6 +4489,15 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@
|
|||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^5.13.2",
|
||||
"axios": "^1.6.7",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.8.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import http from "../http";
|
||||
import axios from "axios";
|
||||
|
||||
export interface MeetingVO {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
creatorId: number;
|
||||
creatorName?: string;
|
||||
title: string;
|
||||
meetingTime: string;
|
||||
participants: string;
|
||||
|
|
@ -114,3 +116,12 @@ export const getMeetingProgress = (id: number) => {
|
|||
`/api/biz/meeting/${id}/progress`
|
||||
);
|
||||
};
|
||||
|
||||
export const downloadMeetingSummary = (id: number, format: 'pdf' | 'word') => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
||||
import {
|
||||
HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
|
||||
AudioOutlined, RobotOutlined,
|
||||
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
|
||||
PlayCircleOutlined, FileTextOutlined
|
||||
HistoryOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
RobotOutlined,
|
||||
CalendarOutlined,
|
||||
TeamOutlined,
|
||||
RiseOutlined,
|
||||
ClockCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -13,7 +20,6 @@ import { MeetingVO, getMeetingProgress, MeetingProgress } from '../api/business/
|
|||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 新增进度显示子组件
|
||||
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
|
||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||
|
||||
|
|
@ -23,10 +29,12 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
|
|||
const fetchProgress = async () => {
|
||||
try {
|
||||
const res = await getMeetingProgress(meeting.id);
|
||||
if (res.data && res.data.data) {
|
||||
if (res.data?.data) {
|
||||
setProgress(res.data.data);
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
|
|
@ -42,11 +50,11 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
|
|||
return (
|
||||
<div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}>
|
||||
<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} />
|
||||
{progress?.message || '准备分析中...'}
|
||||
</Text>
|
||||
{!isError && <Text size="small" strong style={{ color: '#1890ff' }}>{percent}%</Text>}
|
||||
{!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
|
||||
</div>
|
||||
<Progress
|
||||
percent={isError ? 100 : percent}
|
||||
|
|
@ -59,24 +67,24 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
|
|||
);
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
export const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const processingCount = Number(stats?.processingTasks || 0);
|
||||
const dashboardLoading = loading && processingCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时
|
||||
const timer = setInterval(fetchDashboardData, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [statsRes, tasksRes] = await Promise.all([
|
||||
getDashboardStats(),
|
||||
getRecentTasks()
|
||||
]);
|
||||
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
|
||||
setStats(statsRes.data.data);
|
||||
setRecentTasks(tasksRes.data.data || []);
|
||||
} catch (err) {
|
||||
|
|
@ -87,7 +95,6 @@ const Dashboard: React.FC = () => {
|
|||
};
|
||||
|
||||
const renderTaskProgress = (item: MeetingVO) => {
|
||||
// 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败
|
||||
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
|
||||
|
||||
return (
|
||||
|
|
@ -100,12 +107,12 @@ const Dashboard: React.FC = () => {
|
|||
{
|
||||
title: '语音转录',
|
||||
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
|
||||
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转换中' : '排队中')
|
||||
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
|
||||
},
|
||||
{
|
||||
title: '智能总结',
|
||||
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
|
||||
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在构思' : '待触发')
|
||||
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
|
||||
},
|
||||
{
|
||||
title: '分析完成',
|
||||
|
|
@ -117,18 +124,23 @@ const Dashboard: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
|
||||
{
|
||||
label: '当前分析中任务',
|
||||
value: stats?.processingTasks,
|
||||
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
|
||||
color: '#faad14'
|
||||
},
|
||||
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
|
||||
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
|
||||
{/* 顶部统计区 */}
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
{statCards.map((s, idx) => (
|
||||
<Col span={6} key={idx}>
|
||||
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
|
|
@ -142,7 +154,6 @@ const Dashboard: React.FC = () => {
|
|||
))}
|
||||
</Row>
|
||||
|
||||
{/* 核心任务流 - 垂直卡片列表 */}
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
|
@ -154,37 +165,36 @@ const Dashboard: React.FC = () => {
|
|||
style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
<List
|
||||
loading={loading}
|
||||
loading={dashboardLoading}
|
||||
dataSource={recentTasks}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Row gutter={32} align="middle">
|
||||
{/* 左:会议基础信息 */}
|
||||
<Col span={8}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
|
||||
{item.title}
|
||||
</Title>
|
||||
<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" size="small"><TeamOutlined /> {item.participants || '系统记录'}</Text>
|
||||
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
|
||||
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
|
||||
</Space>
|
||||
<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>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
{/* 中:线性进度条 */}
|
||||
<Col span={12}>
|
||||
{renderTaskProgress(item)}
|
||||
</Col>
|
||||
|
||||
{/* 右:操作入口 */}
|
||||
<Col span={4} style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type={item.status === 3 ? "primary" : "default"}
|
||||
type={item.status === 3 ? 'primary' : 'default'}
|
||||
ghost={item.status === 3}
|
||||
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => navigate(`/meetings/${item.id}`)}
|
||||
|
|
@ -194,7 +204,6 @@ const Dashboard: React.FC = () => {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 进度实时展示 */}
|
||||
<MeetingProgressDisplay meeting={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
|
|
@ -203,6 +212,7 @@ const Dashboard: React.FC = () => {
|
|||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !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 { 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, SettingOutlined } from '@ant-design/icons';
|
||||
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 {
|
||||
LeftOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
AudioOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
EditOutlined,
|
||||
SyncOutlined,
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
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 { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
|
|
@ -14,7 +59,6 @@ import { SysUser } from '../../types';
|
|||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
// 详情页进度显示组件
|
||||
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
|
||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||
|
||||
|
|
@ -22,24 +66,25 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
|||
const fetchProgress = async () => {
|
||||
try {
|
||||
const res = await getMeetingProgress(meetingId);
|
||||
if (res.data && res.data.data) {
|
||||
if (res.data?.data) {
|
||||
setProgress(res.data.data);
|
||||
if (res.data.data.percent === 100) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
const timer = setInterval(fetchProgress, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [meetingId]);
|
||||
}, [meetingId, onComplete]);
|
||||
|
||||
const percent = progress?.percent || 0;
|
||||
const isError = percent < 0;
|
||||
|
||||
// 格式化剩余时间 (ETA)
|
||||
const formatETA = (seconds?: number) => {
|
||||
if (!seconds || seconds <= 0) return '正在分析中';
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
|
|
@ -49,44 +94,57 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
|||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center',
|
||||
background: '#fff', borderRadius: 16, padding: 40
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>AI 智能分析中</Title>
|
||||
<Progress
|
||||
type="circle"
|
||||
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' }}
|
||||
width={180}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
<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 || '正在准备计算资源...'}
|
||||
</Text>
|
||||
<Text type="secondary">分析过程中,请耐心等待,您可以先去处理其他工作</Text>
|
||||
<Text type="secondary">分析过程中,请耐心等待,你可以先去处理其他工作</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: '32px 0' }} />
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<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>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<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>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" size="small">任务状态</Text>
|
||||
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>{isError ? '已中断' : '正常'}</Title>
|
||||
<Text type="secondary">任务状态</Text>
|
||||
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
|
||||
{isError ? '已中断' : '正常'}
|
||||
</Title>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -122,18 +180,37 @@ const SpeakerEditor: React.FC<{
|
|||
};
|
||||
|
||||
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 }}>
|
||||
<Text type="secondary" size="small">发言人姓名</Text>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
|
||||
<Text type="secondary">发言人姓名</Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入姓名"
|
||||
size="small"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary" size="small">角色标签</Text>
|
||||
<Select 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>)}
|
||||
<Text type="secondary">角色标签</Text>
|
||||
<Select
|
||||
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>
|
||||
</div>
|
||||
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>同步到全文</Button>
|
||||
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>
|
||||
同步到全局
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -150,18 +227,19 @@ const MeetingDetail: React.FC = () => {
|
|||
const [editVisible, setEditVisible] = useState(false);
|
||||
const [summaryVisible, setSummaryVisible] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null);
|
||||
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const [, setUserList] = useState<SysUser[]>([]);
|
||||
const { items: speakerLabels } = useDict('biz_speaker_label');
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 核心权限判断
|
||||
const isOwner = React.useMemo(() => {
|
||||
if (!meeting) return false;
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
const profileStr = sessionStorage.getItem('userProfile');
|
||||
if (profileStr) {
|
||||
const profile = JSON.parse(profileStr);
|
||||
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
|
||||
|
|
@ -179,10 +257,7 @@ const MeetingDetail: React.FC = () => {
|
|||
|
||||
const fetchData = async (meetingId: number) => {
|
||||
try {
|
||||
const [detailRes, transcriptRes] = await Promise.all([
|
||||
getMeetingDetail(meetingId),
|
||||
getTranscripts(meetingId)
|
||||
]);
|
||||
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
||||
setMeeting(detailRes.data.data);
|
||||
setTranscripts(transcriptRes.data.data || []);
|
||||
} catch (err) {
|
||||
|
|
@ -197,29 +272,30 @@ const MeetingDetail: React.FC = () => {
|
|||
const [mRes, pRes, dRes] = await Promise.all([
|
||||
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getAiModelDefault('LLM')
|
||||
getAiModelDefault('LLM'),
|
||||
]);
|
||||
setLlmModels(mRes.data.data.records.filter(m => m.status === 1));
|
||||
setPrompts(pRes.data.data.records.filter(p => p.status === 1));
|
||||
setLlmModels(mRes.data.data.records.filter((m) => m.status === 1));
|
||||
setPrompts(pRes.data.data.records.filter((p) => p.status === 1));
|
||||
summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id });
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const users = await listUsers();
|
||||
setUserList(users || []);
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditMeeting = () => {
|
||||
if (!meeting || !isOwner) return;
|
||||
// 由于后端存储的是姓名字符串,而我们现在需要 ID 匹配,
|
||||
// 这里简单处理:让发起人依然可以修改基础元数据。
|
||||
// 如果需要修改参会人 ID,需要前端存储 ID 列表快照。
|
||||
form.setFieldsValue({
|
||||
...meeting,
|
||||
tags: meeting.tags?.split(',').filter(Boolean)
|
||||
tags: meeting.tags?.split(',').filter(Boolean),
|
||||
});
|
||||
setEditVisible(true);
|
||||
};
|
||||
|
|
@ -231,7 +307,7 @@ const MeetingDetail: React.FC = () => {
|
|||
await updateMeeting({
|
||||
...vals,
|
||||
id: meeting?.id,
|
||||
tags: vals.tags?.join(',')
|
||||
tags: vals.tags?.join(','),
|
||||
});
|
||||
message.success('会议信息已更新');
|
||||
setEditVisible(false);
|
||||
|
|
@ -250,7 +326,7 @@ const MeetingDetail: React.FC = () => {
|
|||
await reSummary({
|
||||
meetingId: Number(id),
|
||||
summaryModelId: vals.summaryModelId,
|
||||
promptId: vals.promptId
|
||||
promptId: vals.promptId,
|
||||
});
|
||||
message.success('已重新发起总结任务');
|
||||
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 (!meeting) return <div style={{ padding: '24px' }}><Empty description="会议不存在" /></div>;
|
||||
|
||||
|
|
@ -291,13 +466,17 @@ const MeetingDetail: React.FC = () => {
|
|||
<Col>
|
||||
<Space direction="vertical" size={4}>
|
||||
<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>
|
||||
<Space split={<Divider type="vertical" />}>
|
||||
<Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
<Space>
|
||||
{meeting.tags?.split(',').filter(Boolean).map(t => <Tag key={t} color="blue">{t}</Tag>)}
|
||||
</Space>
|
||||
<Space>{meeting.tags?.split(',').filter(Boolean).map((t) => <Tag key={t} color="blue">{t}</Tag>)}</Space>
|
||||
<Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
|
@ -305,27 +484,25 @@ const MeetingDetail: React.FC = () => {
|
|||
<Col>
|
||||
<Space>
|
||||
{isOwner && meeting.status === 3 && (
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
type="primary"
|
||||
ghost
|
||||
onClick={() => setSummaryVisible(true)}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
||||
重新总结
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && meeting.status === 2 && (
|
||||
<Button
|
||||
icon={<LoadingOutlined />}
|
||||
type="primary"
|
||||
ghost
|
||||
disabled
|
||||
loading
|
||||
>
|
||||
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
|
||||
正在总结
|
||||
</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>
|
||||
</Space>
|
||||
</Col>
|
||||
|
|
@ -333,73 +510,141 @@ const MeetingDetail: React.FC = () => {
|
|||
</Card>
|
||||
|
||||
<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)} />
|
||||
) : (
|
||||
<Row gutter={24} 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 }}
|
||||
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}>
|
||||
<List dataSource={transcripts} renderItem={(item) => (
|
||||
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}>
|
||||
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
|
||||
title={<Space>
|
||||
<Card
|
||||
title={<span><AudioOutlined /> 语音转录</span>}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }}
|
||||
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}
|
||||
>
|
||||
<List
|
||||
dataSource={transcripts}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }}
|
||||
onClick={() => seekTo(item.startTime)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
|
||||
title={
|
||||
<Space>
|
||||
{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
|
||||
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" size="small" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text>
|
||||
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</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 ? '识别任务进行中...' : '暂无数据' }} />
|
||||
)}
|
||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<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 }}>
|
||||
{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>}
|
||||
<Card
|
||||
title={<span><RobotOutlined /> AI 总结</span>}
|
||||
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>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.markdown-body { font-size: 14px; line-height: 1.8; color: #333; }
|
||||
.markdown-body p { margin-bottom: 16px; }
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; }
|
||||
`}</style>
|
||||
|
||||
{/* 修改基础信息弹窗 - 仅限 Owner */}
|
||||
{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.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></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>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 重新总结抽屉 - 仅限 Owner */}
|
||||
{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.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
||||
<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>
|
||||
</Form.Item>
|
||||
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择新模板">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
<Select placeholder="选择模板">
|
||||
{prompts.map((p) => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Text type="secondary" size="small">提示:重新总结将基于当前的语音转录全文重新生成纪要,原有的总结内容将被覆盖。</Text>
|
||||
<Text type="secondary">提示:重新总结将基于当前语音转录全文重新生成纪要,原有总结内容会被覆盖。</Text>
|
||||
</Form>
|
||||
</Drawer>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -353,15 +353,21 @@ const Meetings: React.FC = () => {
|
|||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
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(() => {
|
||||
if (!hasRunningTasks) return;
|
||||
const timer = setInterval(() => fetchData(true), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasRunningTasks, current, size, searchTitle, viewType]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const fetchData = async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
|
||||
if (res.data && res.data.data) { setData(res.data.data.records); setTotal(res.data.data.total); }
|
||||
} catch (err) {} finally { setLoading(false); }
|
||||
} catch (err) {} finally { if (!silent) setLoading(false); }
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Dashboard from "../pages/Dashboard";
|
||||
import { Dashboard } from "../pages/Dashboard";
|
||||
import Users from "../pages/Users";
|
||||
import Roles from "../pages/Roles";
|
||||
import Permissions from "../pages/Permissions";
|
||||
|
|
@ -48,3 +48,4 @@ export const extraRoutes = [
|
|||
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
|
||||
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg /> }
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue