From 653a9f7ef42a2f9d5f21e65259a194a705a3fdd8 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 26 Mar 2026 11:18:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E5=92=8C=E4=B8=BB=E9=A1=B5=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `RealtimeAsr` 组件,用于创建和配置实时会议 - 新增 `HomePage` 组件,展示最近的会议记录和快速入口 - 新增 `RealtimeAsrSession` 组件,用于实时会议的会中识别和转录 --- backend/pom.xml | 4 + .../controller/biz/DashboardController.java | 15 +- .../controller/biz/MeetingController.java | 897 +++--------- ...tingDTO.java => CreateMeetingCommand.java} | 7 +- .../dto/biz/CreateRealtimeMeetingCommand.java | 23 + .../imeeting/dto/biz/MeetingResummaryDTO.java | 10 + .../dto/biz/MeetingSpeakerUpdateDTO.java | 11 + .../dto/biz/MeetingSummaryExportResult.java | 12 + .../dto/biz/RealtimeMeetingCompleteDTO.java | 8 + .../dto/biz/RealtimeTranscriptItemDTO.java | 12 + .../dto/biz/UpdateMeetingBasicCommand.java | 17 + .../biz/UpdateMeetingParticipantsCommand.java | 9 + .../dto/biz/UpdateMeetingSummaryCommand.java | 9 + .../service/biz/MeetingAccessService.java | 16 + .../service/biz/MeetingCommandService.java | 31 + .../service/biz/MeetingExportService.java | 9 + .../service/biz/MeetingQueryService.java | 21 + .../imeeting/service/biz/MeetingService.java | 21 - .../biz/MeetingSummaryFileService.java | 15 + .../biz/impl/MeetingAccessServiceImpl.java | 107 ++ .../biz/impl/MeetingCommandServiceImpl.java | 217 +++ .../biz/impl/MeetingDomainSupport.java | 198 +++ .../biz/impl/MeetingExportServiceImpl.java | 307 +++++ .../biz/impl/MeetingQueryServiceImpl.java | 130 ++ .../service/biz/impl/MeetingServiceImpl.java | 571 -------- .../impl/MeetingSummaryFileServiceImpl.java | 154 +++ .../src/main/resources/application-dev.yml | 27 + .../src/main/resources/application-prod.yml | 23 + .../src/main/resources/application-test.yml | 59 +- backend/src/main/resources/application.yml | 22 +- .../java/com/imeeting/db/DbAlterTest.java | 56 + frontend/src/api/business/meeting.ts | 69 +- frontend/src/hooks/useAuth.ts | 1 + frontend/src/index.css | 617 ++++++++- frontend/src/layouts/AppLayout.tsx | 65 +- frontend/src/pages/auth/login/index.tsx | 3 + frontend/src/pages/business/HotWords.tsx | 14 +- frontend/src/pages/business/MeetingDetail.tsx | 13 +- frontend/src/pages/business/Meetings.tsx | 18 +- .../src/pages/business/PromptTemplates.tsx | 12 +- frontend/src/pages/business/RealtimeAsr.tsx | 385 ++++++ .../src/pages/business/RealtimeAsrSession.tsx | 633 +++++++++ frontend/src/pages/business/SpeakerReg.tsx | 14 +- frontend/src/pages/dashboard/index.tsx | 10 +- frontend/src/pages/home/index.less | 1220 +++++++++++++++++ frontend/src/pages/home/index.tsx | 303 ++++ frontend/src/routes/routes.tsx | 4 +- frontend/src/store/themeStore.ts | 24 +- 48 files changed, 4936 insertions(+), 1487 deletions(-) rename backend/src/main/java/com/imeeting/dto/biz/{MeetingDTO.java => CreateMeetingCommand.java} (79%) create mode 100644 backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingSpeakerUpdateDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryExportResult.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/RealtimeTranscriptItemDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingParticipantsCommand.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingSummaryCommand.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java create mode 100644 backend/src/main/resources/application-dev.yml create mode 100644 backend/src/main/resources/application-prod.yml create mode 100644 backend/src/test/java/com/imeeting/db/DbAlterTest.java create mode 100644 frontend/src/pages/business/RealtimeAsr.tsx create mode 100644 frontend/src/pages/business/RealtimeAsrSession.tsx create mode 100644 frontend/src/pages/home/index.less create mode 100644 frontend/src/pages/home/index.tsx diff --git a/backend/pom.xml b/backend/pom.xml index 4e8246b..8c2ebaf 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -129,6 +129,10 @@ unisbase-spring-boot-starter 0.1.0 + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java index a4a022f..fed4460 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java @@ -1,9 +1,6 @@ package com.imeeting.controller.biz; - - import com.imeeting.dto.biz.MeetingVO; - -import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.MeetingQueryService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; import org.springframework.security.access.prepost.PreAuthorize; @@ -19,10 +16,10 @@ import java.util.Map; @RequestMapping("/api/biz/dashboard") public class DashboardController { - private final MeetingService meetingService; + private final MeetingQueryService meetingQueryService; - public DashboardController(MeetingService meetingService) { - this.meetingService = meetingService; + public DashboardController(MeetingQueryService meetingQueryService) { + this.meetingQueryService = meetingQueryService; } @GetMapping("/stats") @@ -30,7 +27,7 @@ public class DashboardController { public ApiResponse> getStats() { LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin()); - return ApiResponse.ok(meetingService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin)); + return ApiResponse.ok(meetingQueryService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin)); } @GetMapping("/recent") @@ -38,6 +35,6 @@ public class DashboardController { public ApiResponse> getRecent() { LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin()); - return ApiResponse.ok(meetingService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10)); + return ApiResponse.ok(meetingQueryService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10)); } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index d9d3e41..51b48fa 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -1,33 +1,27 @@ package com.imeeting.controller.biz; - import com.imeeting.common.RedisKeys; -import com.imeeting.dto.biz.MeetingDTO; +import com.imeeting.dto.biz.CreateMeetingCommand; +import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingResummaryDTO; +import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; +import com.imeeting.dto.biz.MeetingSummaryExportResult; import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; -import com.imeeting.entity.biz.AiTask; +import com.imeeting.dto.biz.UpdateMeetingBasicCommand; +import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand; +import com.imeeting.dto.biz.UpdateMeetingSummaryCommand; import com.imeeting.entity.biz.Meeting; - -import com.imeeting.service.biz.AiTaskService; -import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingExportService; +import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.PromptTemplateService; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.unisbase.common.ApiResponse; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; -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; @@ -35,50 +29,51 @@ 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.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; -import org.commonmark.node.Node; -import org.commonmark.parser.Parser; -import org.commonmark.renderer.html.HtmlRenderer; -import org.jsoup.Jsoup; - -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -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") public class MeetingController { - private final MeetingService meetingService; - private final AiTaskService aiTaskService; + private final MeetingQueryService meetingQueryService; + private final MeetingCommandService meetingCommandService; + private final MeetingAccessService meetingAccessService; + private final MeetingExportService meetingExportService; private final PromptTemplateService promptTemplateService; private final StringRedisTemplate redisTemplate; private final String uploadPath; private final String resourcePrefix; - public MeetingController(MeetingService meetingService, - AiTaskService aiTaskService, + public MeetingController(MeetingQueryService meetingQueryService, + MeetingCommandService meetingCommandService, + MeetingAccessService meetingAccessService, + MeetingExportService meetingExportService, PromptTemplateService promptTemplateService, StringRedisTemplate redisTemplate, @Value("${unisbase.app.upload-path}") String uploadPath, @Value("${unisbase.app.resource-prefix}") String resourcePrefix) { - this.meetingService = meetingService; - this.aiTaskService = aiTaskService; + this.meetingQueryService = meetingQueryService; + this.meetingCommandService = meetingCommandService; + this.meetingAccessService = meetingAccessService; + this.meetingExportService = meetingExportService; this.promptTemplateService = promptTemplateService; this.redisTemplate = redisTemplate; this.uploadPath = uploadPath; @@ -88,29 +83,30 @@ public class MeetingController { @GetMapping("/{id}/progress") @PreAuthorize("isAuthenticated()") public ApiResponse> getProgress(@PathVariable Long id) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + String key = RedisKeys.meetingProgressKey(id); String json = redisTemplate.opsForValue().get(key); if (json != null) { try { return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class)); - } catch (Exception e) { - return ApiResponse.error("解析进度异常"); + } catch (Exception ex) { + return ApiResponse.error("Progress parse failed"); } } - Meeting m = meetingService.getById(id); Map fallback = new HashMap<>(); - if (m != null) { - if (m.getStatus() == 3) { - fallback.put("percent", 100); - fallback.put("message", "分析已完成"); - } else if (m.getStatus() == 4) { - fallback.put("percent", -1); - fallback.put("message", "分析失败"); - } else { - fallback.put("percent", 0); - fallback.put("message", "等待处理..."); - } + if (meeting.getStatus() == 3) { + fallback.put("percent", 100); + fallback.put("message", "Completed"); + } else if (meeting.getStatus() == 4) { + fallback.put("percent", -1); + fallback.put("message", "Failed"); + } else { + fallback.put("percent", 0); + fallback.put("message", "Waiting..."); } return ApiResponse.ok(fallback); } @@ -121,56 +117,40 @@ public class MeetingController { String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; String uploadDir = basePath + "audio/"; File dir = new File(uploadDir); - if (!dir.exists()) dir.mkdirs(); + if (!dir.exists()) { + dir.mkdirs(); + } String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); file.transferTo(new File(uploadDir + fileName)); String baseResourcePrefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; - return ApiResponse.ok(baseResourcePrefix+"audio/" + fileName); + return ApiResponse.ok(baseResourcePrefix + "audio/" + fileName); } @PostMapping @PreAuthorize("isAuthenticated()") - public ApiResponse create(@RequestBody MeetingDTO dto) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (dto.getPromptId() != null) { - boolean enabled = promptTemplateService.isTemplateEnabledForUser( - dto.getPromptId(), - loginUser.getTenantId(), - loginUser.getUserId(), - loginUser.getIsPlatformAdmin(), - loginUser.getIsTenantAdmin() - ); - if (!enabled) { - return ApiResponse.error("总结模板不可用或已被你禁用"); - } - } - dto.setTenantId(loginUser.getTenantId()); - dto.setCreatorId(loginUser.getUserId()); - dto.setCreatorName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername()); - return ApiResponse.ok(meetingService.createMeeting(dto)); + public ApiResponse create(@RequestBody CreateMeetingCommand command) { + LoginUser loginUser = currentLoginUser(); + assertPromptAvailable(command.getPromptId(), loginUser); + return ApiResponse.ok(meetingCommandService.createMeeting( + command, + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser) + )); } @PostMapping("/realtime/start") @PreAuthorize("isAuthenticated()") - public ApiResponse createRealtime(@RequestBody MeetingDTO dto) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (dto.getPromptId() != null) { - boolean enabled = promptTemplateService.isTemplateEnabledForUser( - dto.getPromptId(), - loginUser.getTenantId(), - loginUser.getUserId(), - loginUser.getIsPlatformAdmin(), - loginUser.getIsTenantAdmin() - ); - if (!enabled) { - return ApiResponse.error("总结模板不可用或已被你禁用"); - } - } - dto.setTenantId(loginUser.getTenantId()); - dto.setCreatorId(loginUser.getUserId()); - dto.setCreatorName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername()); - return ApiResponse.ok(meetingService.createRealtimeMeeting(dto)); + public ApiResponse createRealtime(@RequestBody CreateRealtimeMeetingCommand command) { + LoginUser loginUser = currentLoginUser(); + assertPromptAvailable(command.getPromptId(), loginUser); + return ApiResponse.ok(meetingCommandService.createRealtimeMeeting( + command, + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser) + )); } @GetMapping("/page") @@ -181,248 +161,153 @@ public class MeetingController { @RequestParam(required = false) String title, @RequestParam(defaultValue = "all") String viewType) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + LoginUser loginUser = currentLoginUser(); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); - return ApiResponse.ok(meetingService.pageMeetings(current, size, title, - loginUser.getTenantId(), loginUser.getUserId(), - loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(), - viewType, isAdmin)); + return ApiResponse.ok(meetingQueryService.pageMeetings( + current, + size, + title, + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser), + viewType, + isAdmin + )); } - @GetMapping("/detail/{id}") + @GetMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse getDetail(@PathVariable Long id) { - return ApiResponse.ok(meetingService.getDetail(id)); + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + return ApiResponse.ok(meetingQueryService.getDetail(id)); } @GetMapping("/{id}/summary/export") @PreAuthorize("isAuthenticated()") public ResponseEntity exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Meeting meetingEntity = meetingService.getById(id); - if (meetingEntity == null) { - throw new RuntimeException("数据未找到,请刷新后重试"); - } - if (!canAccessMeeting(meetingEntity, loginUser)) { - throw new RuntimeException("无权下载此会议总结"); + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanExportMeeting(meeting, loginUser); + + MeetingVO meetingDetail = meetingQueryService.getDetail(id); + if (meetingDetail == null) { + throw new RuntimeException("Meeting not found"); } - MeetingVO meeting = meetingService.getDetail(id); - if (meeting == null) { - throw new RuntimeException("数据未找到,请刷新后重试"); - } - - AiTask latestSummaryTask = findLatestSummaryTask(meetingEntity); - if (latestSummaryTask == null || latestSummaryTask.getResultFilePath() == null || latestSummaryTask.getResultFilePath().isBlank()) { - throw new RuntimeException(" AI总结为空"); - } - - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path summarySourcePath = Paths.get(basePath, latestSummaryTask.getResultFilePath().replace("\\", "/")); - if (!Files.exists(summarySourcePath)) { - throw new RuntimeException("总结源文件不存在,请重新总结后再试"); - } - - String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty()) - ? "meeting-summary-" + id - : meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_"); - - try { - byte[] bytes; - String ext; - String contentType; - Path exportDir = Paths.get(basePath, "meetings", String.valueOf(id), "exports"); - Files.createDirectories(exportDir); - - if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) { - ext = "docx"; - contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - } else if ("pdf".equalsIgnoreCase(format)) { - ext = "pdf"; - contentType = MediaType.APPLICATION_PDF_VALUE; - } else { - throw new RuntimeException("格式化失败"); - } - - Path exportPath = exportDir.resolve(latestSummaryTask.getId() + "." + ext); - boolean needRegenerate = !Files.exists(exportPath) || - Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis(); - - if (needRegenerate) { - String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8); - meeting.setSummaryContent(stripFrontMatter(markdown)); - bytes = "docx".equals(ext) ? buildWordBytes(meeting) : buildPdfBytes(meeting); - Files.write(exportPath, bytes); - } else { - bytes = Files.readAllBytes(exportPath); - } - - 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); - } + MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format); + String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename) + .contentType(MediaType.parseMediaType(exportResult.getContentType())) + .body(exportResult.getContent()); } - private AiTask findLatestSummaryTask(Meeting meeting) { - if (meeting.getLatestSummaryTaskId() != null) { - AiTask task = aiTaskService.getById(meeting.getLatestSummaryTaskId()); - if (task != null && "SUMMARY".equals(task.getTaskType()) && Integer.valueOf(2).equals(task.getStatus()) - && task.getResultFilePath() != null && !task.getResultFilePath().isBlank()) { - return task; - } - } - - return aiTaskService.getOne(new LambdaQueryWrapper() - .eq(AiTask::getMeetingId, meeting.getId()) - .eq(AiTask::getTaskType, "SUMMARY") - .eq(AiTask::getStatus, 2) - .isNotNull(AiTask::getResultFilePath) - .orderByDesc(AiTask::getId) - .last("LIMIT 1")); - } - - private boolean canAccessMeeting(Meeting meeting, LoginUser user) { - if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin())) { - return true; - } - if (meeting.getCreatorId() != null && meeting.getCreatorId().equals(user.getUserId())) { - return true; - } - if (meeting.getParticipants() == null || meeting.getParticipants().isBlank()) { - return false; - } - String target = "," + user.getUserId() + ","; - return ("," + meeting.getParticipants() + ",").contains(target); - } - - private String stripFrontMatter(String markdown) { - if (markdown == null || markdown.isBlank()) { - return markdown; - } - if (!markdown.startsWith("---")) { - return unwrapMarkdownFence(markdown); - } - int second = markdown.indexOf("\n---", 3); - if (second < 0) { - return unwrapMarkdownFence(markdown); - } - int contentStart = second + 4; - if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') { - contentStart++; - } - return unwrapMarkdownFence(markdown.substring(contentStart).trim()); - } - - private String unwrapMarkdownFence(String markdown) { - if (markdown == null) { - return null; - } - String normalized = markdown.trim(); - if (!normalized.startsWith("```")) { - return normalized; - } - int firstLineEnd = normalized.indexOf('\n'); - if (firstLineEnd < 0) { - return normalized; - } - String firstLine = normalized.substring(0, firstLineEnd).trim().toLowerCase(); - if (!"```".equals(firstLine) && !"```markdown".equals(firstLine) && !"```md".equals(firstLine)) { - return normalized; - } - int lastFence = normalized.lastIndexOf("\n```"); - if (lastFence <= firstLineEnd) { - return normalized.substring(firstLineEnd + 1).trim(); - } - return normalized.substring(firstLineEnd + 1, lastFence).trim(); - } - - @GetMapping("/transcripts/{id}") + @GetMapping("/{id}/transcripts") @PreAuthorize("isAuthenticated()") public ApiResponse> getTranscripts(@PathVariable Long id) { - return ApiResponse.ok(meetingService.getTranscripts(id)); + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } @PostMapping("/{id}/realtime/transcripts") @PreAuthorize("isAuthenticated()") public ApiResponse appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List items) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Meeting existing = meetingService.getById(id); - if (existing == null) { - return ApiResponse.error("会议不存在"); - } - if (!existing.getCreatorId().equals(loginUser.getUserId()) - && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) - && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { - return ApiResponse.error("无权写入此会议的实时转录"); - } - meetingService.appendRealtimeTranscripts(id, items); + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingCommandService.appendRealtimeTranscripts(id, items); return ApiResponse.ok(true); } @PostMapping("/{id}/realtime/complete") @PreAuthorize("isAuthenticated()") public ApiResponse completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Meeting existing = meetingService.getById(id); - if (existing == null) { - return ApiResponse.error("会议不存在"); - } - if (!existing.getCreatorId().equals(loginUser.getUserId()) - && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) - && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { - return ApiResponse.error("无权结束此实时会议"); - } - meetingService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null); + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null); return ApiResponse.ok(true); } @PutMapping("/speaker") @PreAuthorize("isAuthenticated()") - public ApiResponse updateSpeaker(@RequestBody Map params) { - Long meetingId = Long.valueOf(params.get("meetingId").toString()); - String speakerId = params.get("speakerId").toString(); - String newName = params.get("newName") != null ? params.get("newName").toString() : null; - String label = params.get("label") != null ? params.get("label").toString() : null; - - meetingService.updateSpeakerInfo(meetingId, speakerId, newName, label); + public ApiResponse updateSpeaker(@RequestBody MeetingSpeakerUpdateDTO dto) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(dto.getMeetingId()); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + meetingCommandService.updateSpeakerInfo(dto.getMeetingId(), dto.getSpeakerId(), dto.getNewName(), dto.getLabel()); return ApiResponse.ok(true); } - @PutMapping("/participants") + @PutMapping("/{id}/participants") @PreAuthorize("isAuthenticated()") - public ApiResponse updateParticipants(@RequestBody Map params) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long meetingId = Long.valueOf(params.get("meetingId").toString()); - String participants = params.get("participants") != null ? params.get("participants").toString() : ""; - - Meeting existing = meetingService.getById(meetingId); - if (existing == null) { - return ApiResponse.error("会议不存在"); - } - - if (!existing.getCreatorId().equals(loginUser.getUserId()) - && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) - && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { - return ApiResponse.error("无权修改此会议参会人"); - } - - meetingService.updateMeetingParticipants(meetingId, participants); + public ApiResponse updateParticipants(@PathVariable Long id, + @RequestBody UpdateMeetingParticipantsCommand command) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + command.setMeetingId(id); + meetingCommandService.updateMeetingParticipants(command.getMeetingId(), command.getParticipants()); return ApiResponse.ok(true); } - @PostMapping("/re-summary") + @PostMapping("/{id}/summary/regenerate") @PreAuthorize("isAuthenticated()") - public ApiResponse reSummary(@RequestBody Map params) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long meetingId = Long.valueOf(params.get("meetingId").toString()); - Long summaryModelId = Long.valueOf(params.get("summaryModelId").toString()); - Long promptId = Long.valueOf(params.get("promptId").toString()); + public ApiResponse reSummary(@PathVariable Long id, @RequestBody MeetingResummaryDTO dto) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + dto.setMeetingId(id); + assertPromptAvailable(dto.getPromptId(), loginUser); + meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId()); + return ApiResponse.ok(true); + } + + @PutMapping("/{id}/basic") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + command.setMeetingId(id); + meetingCommandService.updateMeetingBasic(command); + return ApiResponse.ok(true); + } + + @PutMapping("/{id}/summary") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateSummary(@PathVariable Long id, @RequestBody UpdateMeetingSummaryCommand command) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + command.setMeetingId(id); + meetingCommandService.updateSummaryContent(command.getMeetingId(), command.getSummaryContent()); + return ApiResponse.ok(true); + } + + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + meetingCommandService.deleteMeeting(id); + return ApiResponse.ok(true); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } + + private void assertPromptAvailable(Long promptId, LoginUser loginUser) { + if (promptId == null) { + return; + } boolean enabled = promptTemplateService.isTemplateEnabledForUser( promptId, loginUser.getTenantId(), @@ -431,435 +316,11 @@ public class MeetingController { loginUser.getIsTenantAdmin() ); if (!enabled) { - return ApiResponse.error("总结模板不可用或已被你禁用"); - } - - meetingService.reSummary(meetingId, summaryModelId, promptId); - return ApiResponse.ok(true); - } - - @PutMapping - @PreAuthorize("isAuthenticated()") - public ApiResponse update(@RequestBody Meeting meeting) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - 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())) { - return ApiResponse.error("无权修改此会议信息"); - } - - if (meeting.getSummaryContent() != null) { - meetingService.updateSummaryContent(meeting.getId(), meeting.getSummaryContent()); - } - return ApiResponse.ok(meetingService.updateById(meeting)); - } - - @DeleteMapping("/{id}") - @PreAuthorize("isAuthenticated()") - public ApiResponse delete(@PathVariable Long id) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - 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())) { - 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(); + throw new RuntimeException("Summary template unavailable"); } } - private byte[] buildPdfBytes(MeetingVO meeting) throws IOException { - Parser parser = Parser.builder().build(); - String markdown = meeting.getSummaryContent() == null ? "" : meeting.getSummaryContent(); - Node document = parser.parse(markdown); - HtmlRenderer renderer = HtmlRenderer.builder().build(); - String htmlBody = renderer.render(document); - - String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle(); - String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString(); - String participants = meeting.getParticipants() == null ? "未指定" : meeting.getParticipants(); - - String html = "" + - "
" + - "

" + title + "

" + - "
" + - "会议时间:" + time + "" + - "|" + - "参会人:" + participants + "" + - "
" + - "
" + htmlBody + "
" + - "
" + - "由 iMeeting 智能助手生成" + - "
" + - ""; - - org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html); - jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml); - String xhtml = jsoupDoc.html(); - - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - PdfRendererBuilder builder = new PdfRendererBuilder(); - builder.useFastMode(); - - // Register fonts from classpath - try { - java.io.InputStream fontStream = getClass().getResourceAsStream("/fonts/simsunb.ttf"); - if (fontStream != null) { - File tempFont = File.createTempFile("simsunb", ".ttf"); - tempFont.deleteOnExit(); - java.nio.file.Files.copy(fontStream, tempFont.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); - builder.useFont(tempFont, "SimSun"); - fontStream.close(); - } else { - System.out.println("Warning: simsunb.ttf not found in classpath (/fonts/simsunb.ttf)."); - } - - java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf"); - if (notoStream != null) { - File tempNoto = File.createTempFile("notosans", ".ttf"); - tempNoto.deleteOnExit(); - java.nio.file.Files.copy(notoStream, tempNoto.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); - builder.useFont(tempNoto, "NotoSansSC"); - notoStream.close(); - } - } catch (Exception e) { - System.out.println("Error loading font from classpath: " + e.getMessage()); - } - - builder.withHtmlContent(xhtml, null); - builder.toStream(out); - builder.run(); - return out.toByteArray(); - } catch (Exception e) { - throw new IOException("PDF generation failed", e); - } - } - - private void appendMarkdownRuns(XWPFParagraph p, String text, boolean defaultBold, int size) { - String input = text == null ? "" : text; - Matcher m = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); - int start = 0; - while (m.find()) { - String normal = toPlainInline(input.substring(start, m.start())); - if (!normal.isEmpty()) { - XWPFRun run = p.createRun(); - run.setBold(defaultBold); - run.setFontSize(size); - run.setText(normal); - } - String boldText = toPlainInline(m.group(1)); - if (!boldText.isEmpty()) { - XWPFRun run = p.createRun(); - run.setBold(true); - run.setFontSize(size); - run.setText(boldText); - } - start = m.end(); - } - String tail = toPlainInline(input.substring(start)); - if (!tail.isEmpty()) { - XWPFRun run = p.createRun(); - run.setBold(defaultBold); - run.setFontSize(size); - run.setText(tail); - } - } - - private PdfCtx writeWrappedPdf(PDDocument document, PdfCtx ctx, String text, List fonts, float fontSize, - float lineHeight, float margin, float maxWidth) throws IOException { - for (String line : wrapByWidth(text, maxWidth, fonts, fontSize)) { - if (ctx.y < margin + lineHeight) { - ctx.content.close(); - ctx = newPdfPage(document, margin); - } - ctx.content.beginText(); - ctx.content.newLineAtOffset(margin, ctx.y); - writeLineWithFontFallback(ctx.content, line, fonts, fontSize); - ctx.content.endText(); - ctx.y -= lineHeight; - } - return ctx; - } - - private List wrapByWidth(String text, float maxWidth, List fonts, float fontSize) throws IOException { - String content = text == null ? "" : text; - if (content.isEmpty()) return List.of(""); - - List lines = new ArrayList<>(); - StringBuilder current = new StringBuilder(); - float currentWidth = 0f; - for (int i = 0; i < content.length(); ) { - int codePoint = content.codePointAt(i); - String ch = normalizePdfChar(codePoint); - PDFont font = pickFontForChar(ch, fonts); - if (font == null) { - ch = "?"; - font = pickFontForChar(ch, fonts); - } - if (font == null) { - i += Character.charCount(codePoint); - continue; - } - float charWidth = getCharWidth(font, ch, fontSize); - - if (currentWidth + charWidth <= maxWidth || current.length() == 0) { - current.append(ch); - currentWidth += charWidth; - } else { - lines.add(current.toString()); - current.setLength(0); - current.append(ch); - currentWidth = charWidth; - } - i += Character.charCount(codePoint); - } - if (current.length() > 0) { - lines.add(current.toString()); - } - return lines; - } - - private List loadPdfFonts(PDDocument document) { - List fonts = new ArrayList<>(); - String[] candidates = new String[]{ - "C:/Windows/Fonts/msyh.ttf", - "C:/Windows/Fonts/simhei.ttf", - "C:/Windows/Fonts/simsun.ttc", - "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttf", - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf" - }; - - for (String path : candidates) { - try { - File file = new File(path); - if (!file.exists()) continue; - if (path.toLowerCase().endsWith(".ttc")) { - try (TrueTypeCollection ttc = new TrueTypeCollection(file)) { - ttc.processAllFonts(font -> { - try { - fonts.add(PDType0Font.load(document, font, true)); - } catch (Exception ignored) { - } - }); - } - } else { - fonts.add(PDType0Font.load(document, file)); - } - } catch (Exception ignored) { - } - } - if (fonts.isEmpty()) { - fonts.add(PDType1Font.HELVETICA); - } - return fonts; - } - - private List parseMarkdownBlocks(String markdown) { - List blocks = new ArrayList<>(); - if (markdown == null || markdown.trim().isEmpty()) { - return blocks; - } - - String[] lines = markdown.replace("\r\n", "\n").split("\n"); - StringBuilder paragraph = new StringBuilder(); - - for (String raw : lines) { - String line = raw == null ? "" : raw.trim(); - if (line.isEmpty()) { - flushParagraph(blocks, paragraph); - continue; - } - if (line.startsWith("#")) { - flushParagraph(blocks, paragraph); - int level = 0; - while (level < line.length() && line.charAt(level) == '#') { - level++; - } - level = Math.min(level, 6); - String text = line.substring(level).trim(); - blocks.add(new MdBlock(MdType.HEADING, level, text)); - continue; - } - if (line.startsWith("- ") || line.startsWith("* ")) { - flushParagraph(blocks, paragraph); - blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); - continue; - } - Matcher ordered = Pattern.compile("^\\d+\\.\\s+(.*)$").matcher(line); - if (ordered.find()) { - flushParagraph(blocks, paragraph); - blocks.add(new MdBlock(MdType.LIST, 0, ordered.group(1).trim())); - continue; - } - - if (paragraph.length() > 0) paragraph.append(' '); - paragraph.append(line); - } - flushParagraph(blocks, paragraph); - return blocks; - } - - private void flushParagraph(List blocks, StringBuilder paragraph) { - if (paragraph.length() > 0) { - blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString())); - paragraph.setLength(0); - } - } - - private String toPlainInline(String input) { - if (input == null) return ""; - return input - .replaceAll("`([^`]+)`", "$1") - .replaceAll("\\*\\*(.*?)\\*\\*", "$1") - .replaceAll("\\*(.*?)\\*", "$1") - .replaceAll("\\[(.*?)]\\((.*?)\\)", "$1"); - } - - private void writeLineWithFontFallback(PDPageContentStream content, String line, List fonts, float fontSize) throws IOException { - if (line == null || line.isEmpty()) return; - PDFont currentFont = null; - StringBuilder segment = new StringBuilder(); - for (int i = 0; i < line.length(); ) { - int codePoint = line.codePointAt(i); - String ch = normalizePdfChar(codePoint); - PDFont font = pickFontForChar(ch, fonts); - if (font == null) { - ch = "?"; - font = pickFontForChar(ch, fonts); - } - if (font == null) { - i += Character.charCount(codePoint); - continue; - } - if (currentFont == null) { - currentFont = font; - } - if (font != currentFont) { - content.setFont(currentFont, fontSize); - content.showText(segment.toString()); - segment.setLength(0); - currentFont = font; - } - segment.append(ch); - i += Character.charCount(codePoint); - } - if (segment.length() > 0) { - content.setFont(currentFont, fontSize); - content.showText(segment.toString()); - } - } - - private PDFont pickFontForChar(String ch, List fonts) { - for (PDFont font : fonts) { - try { - font.encode(ch); - return font; - } catch (Exception ignored) { - } - } - return null; - } - - private float getCharWidth(PDFont font, String ch, float fontSize) { - try { - return font.getStringWidth(ch) / 1000f * fontSize; - } catch (Exception e) { - return fontSize; - } - } - - private String normalizePdfChar(int codePoint) { - if (codePoint == 0x2022) return "-"; - if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) return " "; - return new String(Character.toChars(codePoint)); - } - - private PdfCtx newPdfPage(PDDocument document, float margin) throws IOException { - PDPage page = new PDPage(PDRectangle.A4); - document.addPage(page); - PDPageContentStream content = new PDPageContentStream(document, page); - float y = page.getMediaBox().getHeight() - margin; - return new PdfCtx(content, y); - } - - private enum MdType { - HEADING, - LIST, - PARAGRAPH - } - - private static class MdBlock { - private final MdType type; - private final int level; - private final String text; - - private MdBlock(MdType type, int level, String text) { - this.type = type; - this.level = level; - this.text = text; - } - } - - private static class PdfCtx { - private final PDPageContentStream content; - private float y; - - private PdfCtx(PDPageContentStream content, float y) { - this.content = content; - this.y = y; - } + private String resolveCreatorName(LoginUser loginUser) { + return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java similarity index 79% rename from backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java rename to backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java index 5027ec0..3718a85 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java @@ -2,13 +2,12 @@ package com.imeeting.dto.biz; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; + import java.time.LocalDateTime; import java.util.List; @Data -public class MeetingDTO { - private Long id; - private Long tenantId; +public class CreateMeetingCommand { private String title; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -17,8 +16,6 @@ public class MeetingDTO { private String participants; private String tags; private String audioUrl; - private Long creatorId; - private String creatorName; private Long asrModelId; private Long summaryModelId; private Long promptId; diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java new file mode 100644 index 0000000..f86f647 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java @@ -0,0 +1,23 @@ +package com.imeeting.dto.biz; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class CreateRealtimeMeetingCommand { + private String title; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime meetingTime; + + private String participants; + private String tags; + private Long asrModelId; + private Long summaryModelId; + private Long promptId; + private Integer useSpkId; + private List hotWords; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java new file mode 100644 index 0000000..f9b286f --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java @@ -0,0 +1,10 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class MeetingResummaryDTO { + private Long meetingId; + private Long summaryModelId; + private Long promptId; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSpeakerUpdateDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSpeakerUpdateDTO.java new file mode 100644 index 0000000..3948109 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSpeakerUpdateDTO.java @@ -0,0 +1,11 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class MeetingSpeakerUpdateDTO { + private Long meetingId; + private String speakerId; + private String newName; + private String label; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryExportResult.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryExportResult.java new file mode 100644 index 0000000..19d8697 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryExportResult.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.biz; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MeetingSummaryExportResult { + private byte[] content; + private String contentType; + private String fileName; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java new file mode 100644 index 0000000..8629b1f --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java @@ -0,0 +1,8 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class RealtimeMeetingCompleteDTO { + private String audioUrl; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeTranscriptItemDTO.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeTranscriptItemDTO.java new file mode 100644 index 0000000..b96a8f8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeTranscriptItemDTO.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class RealtimeTranscriptItemDTO { + private String speakerId; + private String speakerName; + private String content; + private Integer startTime; + private Integer endTime; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java new file mode 100644 index 0000000..6876f96 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.biz; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class UpdateMeetingBasicCommand { + private Long meetingId; + private String title; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime meetingTime; + + private String tags; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingParticipantsCommand.java b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingParticipantsCommand.java new file mode 100644 index 0000000..23f42d1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingParticipantsCommand.java @@ -0,0 +1,9 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class UpdateMeetingParticipantsCommand { + private Long meetingId; + private String participants; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingSummaryCommand.java b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingSummaryCommand.java new file mode 100644 index 0000000..17433c9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingSummaryCommand.java @@ -0,0 +1,9 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class UpdateMeetingSummaryCommand { + private Long meetingId; + private String summaryContent; +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java new file mode 100644 index 0000000..021e03f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java @@ -0,0 +1,16 @@ +package com.imeeting.service.biz; + +import com.imeeting.entity.biz.Meeting; +import com.unisbase.security.LoginUser; + +public interface MeetingAccessService { + Meeting requireMeeting(Long meetingId); + + void assertCanViewMeeting(Meeting meeting, LoginUser loginUser); + + void assertCanEditMeeting(Meeting meeting, LoginUser loginUser); + + void assertCanManageRealtimeMeeting(Meeting meeting, LoginUser loginUser); + + void assertCanExportMeeting(Meeting meeting, LoginUser loginUser); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java new file mode 100644 index 0000000..8246c46 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -0,0 +1,31 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.CreateMeetingCommand; +import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; +import com.imeeting.dto.biz.UpdateMeetingBasicCommand; + +import java.util.List; + +public interface MeetingCommandService { + MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName); + + MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName); + + void deleteMeeting(Long id); + + void appendRealtimeTranscripts(Long meetingId, List items); + + void completeRealtimeMeeting(Long meetingId, String audioUrl); + + void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); + + void updateMeetingBasic(UpdateMeetingBasicCommand command); + + void updateMeetingParticipants(Long meetingId, String participants); + + void updateSummaryContent(Long meetingId, String summaryContent); + + void reSummary(Long meetingId, Long summaryModelId, Long promptId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java new file mode 100644 index 0000000..0e64f88 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java @@ -0,0 +1,9 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.MeetingSummaryExportResult; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; + +public interface MeetingExportService { + MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java new file mode 100644 index 0000000..d18914e --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java @@ -0,0 +1,21 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.MeetingTranscriptVO; +import com.imeeting.dto.biz.MeetingVO; +import com.unisbase.dto.PageResult; + +import java.util.List; +import java.util.Map; + +public interface MeetingQueryService { + PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, + Long userId, String userName, String viewType, boolean isAdmin); + + MeetingVO getDetail(Long id); + + List getTranscripts(Long meetingId); + + Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin); + + List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java index b9d286f..4d14533 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java @@ -2,28 +2,7 @@ package com.imeeting.service.biz; import com.baomidou.mybatisplus.extension.service.IService; -import com.imeeting.dto.biz.MeetingDTO; -import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; -import com.imeeting.dto.biz.MeetingTranscriptVO; -import com.imeeting.dto.biz.MeetingVO; import com.imeeting.entity.biz.Meeting; -import com.unisbase.dto.PageResult; - -import java.util.List; public interface MeetingService extends IService { - MeetingVO createMeeting(MeetingDTO dto); - MeetingVO createRealtimeMeeting(MeetingDTO dto); - PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin); - void deleteMeeting(Long id); - MeetingVO getDetail(Long id); - List getTranscripts(Long meetingId); - void appendRealtimeTranscripts(Long meetingId, List items); - void completeRealtimeMeeting(Long meetingId, String audioUrl); - void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); - void updateMeetingParticipants(Long meetingId, String participants); - void updateSummaryContent(Long meetingId, String summaryContent); - void reSummary(Long meetingId, Long summaryModelId, Long promptId); - java.util.Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin); - List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java new file mode 100644 index 0000000..62c820c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java @@ -0,0 +1,15 @@ +package com.imeeting.service.biz; + +import com.imeeting.entity.biz.Meeting; + +import java.nio.file.Path; + +public interface MeetingSummaryFileService { + Path requireSummarySourcePath(Meeting meeting); + + String loadSummaryContent(Meeting meeting); + + void updateSummaryContent(Meeting meeting, String summaryContent); + + String stripFrontMatter(String markdown); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java new file mode 100644 index 0000000..d5ac508 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java @@ -0,0 +1,107 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.entity.biz.Meeting; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.service.biz.MeetingAccessService; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MeetingAccessServiceImpl implements MeetingAccessService { + + private final MeetingMapper meetingMapper; + + @Override + public Meeting requireMeeting(Long meetingId) { + Meeting meeting = meetingMapper.selectById(meetingId); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + return meeting; + } + + @Override + public void assertCanViewMeeting(Meeting meeting, LoginUser loginUser) { + if (isPlatformAdmin(loginUser)) { + return; + } + if (!isSameTenant(meeting, loginUser)) { + throw new RuntimeException("无权查看此会议"); + } + if (isTenantAdmin(loginUser)) { + return; + } + if (isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) { + return; + } + throw new RuntimeException("无权查看此会议"); + } + + @Override + public void assertCanEditMeeting(Meeting meeting, LoginUser loginUser) { + if (isPlatformAdmin(loginUser)) { + return; + } + if (!isSameTenant(meeting, loginUser)) { + throw new RuntimeException("无权修改此会议"); + } + if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) { + return; + } + throw new RuntimeException("无权修改此会议"); + } + + @Override + public void assertCanManageRealtimeMeeting(Meeting meeting, LoginUser loginUser) { + if (isPlatformAdmin(loginUser)) { + return; + } + if (!isSameTenant(meeting, loginUser)) { + throw new RuntimeException("无权操作此实时会议"); + } + if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) { + return; + } + throw new RuntimeException("无权操作此实时会议"); + } + + @Override + public void assertCanExportMeeting(Meeting meeting, LoginUser loginUser) { + if (isPlatformAdmin(loginUser)) { + return; + } + if (!isSameTenant(meeting, loginUser)) { + throw new RuntimeException("无权导出此会议"); + } + if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) { + return; + } + throw new RuntimeException("无权导出此会议"); + } + + private boolean isPlatformAdmin(LoginUser loginUser) { + return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()); + } + + private boolean isTenantAdmin(LoginUser loginUser) { + return Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + } + + private boolean isSameTenant(Meeting meeting, LoginUser loginUser) { + return meeting.getTenantId() != null && meeting.getTenantId().equals(loginUser.getTenantId()); + } + + private boolean isCreator(Meeting meeting, LoginUser loginUser) { + return meeting.getCreatorId() != null && meeting.getCreatorId().equals(loginUser.getUserId()); + } + + private boolean isParticipant(Meeting meeting, LoginUser loginUser) { + if (meeting.getParticipants() == null || meeting.getParticipants().isBlank()) { + return false; + } + String target = "," + loginUser.getUserId() + ","; + return ("," + meeting.getParticipants() + ",").contains(target); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java new file mode 100644 index 0000000..e762e66 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -0,0 +1,217 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.imeeting.dto.biz.CreateMeetingCommand; +import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; +import com.imeeting.dto.biz.UpdateMeetingBasicCommand; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.HotWordService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.MeetingSummaryFileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MeetingCommandServiceImpl implements MeetingCommandService { + + private final MeetingService meetingService; + private final AiTaskService aiTaskService; + private final HotWordService hotWordService; + private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper; + private final MeetingSummaryFileService meetingSummaryFileService; + private final MeetingDomainSupport meetingDomainSupport; + + @Override + @Transactional(rollbackFor = Exception.class) + public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), + command.getAudioUrl(), tenantId, creatorId, creatorName, 0); + meetingService.save(meeting); + meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())); + meetingService.updateById(meeting); + + AiTask asrTask = new AiTask(); + asrTask.setMeetingId(meeting.getId()); + asrTask.setTaskType("ASR"); + asrTask.setStatus(0); + + Map asrConfig = new HashMap<>(); + asrConfig.put("asrModelId", command.getAsrModelId()); + asrConfig.put("useSpkId", command.getUseSpkId() != null ? command.getUseSpkId() : 1); + + List finalHotWords = command.getHotWords(); + if (finalHotWords == null || finalHotWords.isEmpty()) { + finalHotWords = hotWordService.list(new LambdaQueryWrapper() + .eq(HotWord::getTenantId, meeting.getTenantId()) + .eq(HotWord::getStatus, 1)) + .stream() + .map(HotWord::getWord) + .collect(Collectors.toList()); + } + asrConfig.put("hotWords", finalHotWords); + asrTask.setTaskConfig(asrConfig); + aiTaskService.save(asrTask); + + meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId()); + meetingDomainSupport.publishMeetingCreated(meeting.getId()); + + MeetingVO vo = new MeetingVO(); + meetingDomainSupport.fillMeetingVO(meeting, vo, false); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), + null, tenantId, creatorId, creatorName, 1); + meetingService.save(meeting); + meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId()); + + MeetingVO vo = new MeetingVO(); + meetingDomainSupport.fillMeetingVO(meeting, vo, false); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteMeeting(Long id) { + meetingService.removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void appendRealtimeTranscripts(Long meetingId, List items) { + if (items == null || items.isEmpty()) { + return; + } + + Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .orderByDesc(MeetingTranscript::getSortOrder) + .last("LIMIT 1")) + .stream() + .findFirst() + .map(MeetingTranscript::getSortOrder) + .orElse(0); + + int nextSortOrder = maxSortOrder == null ? 0 : maxSortOrder + 1; + for (RealtimeTranscriptItemDTO item : items) { + if (item.getContent() == null || item.getContent().isBlank()) { + continue; + } + + MeetingTranscript existing = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .eq(MeetingTranscript::getContent, item.getContent().trim()) + .eq(item.getSpeakerId() != null && !item.getSpeakerId().isBlank(), MeetingTranscript::getSpeakerId, item.getSpeakerId()) + .eq(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime()) + .eq(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime()) + .last("LIMIT 1")); + if (existing != null) { + continue; + } + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setMeetingId(meetingId); + transcript.setSpeakerId(meetingDomainSupport.resolveSpeakerId(item.getSpeakerId())); + transcript.setSpeakerName(meetingDomainSupport.resolveSpeakerName(item.getSpeakerId(), item.getSpeakerName())); + transcript.setContent(item.getContent().trim()); + transcript.setStartTime(item.getStartTime()); + transcript.setEndTime(item.getEndTime()); + transcript.setSortOrder(nextSortOrder++); + transcriptMapper.insert(transcript); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void completeRealtimeMeeting(Long meetingId, String audioUrl) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("Meeting not found"); + } + + if (audioUrl != null && !audioUrl.isBlank()) { + meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)); + meetingService.updateById(meeting); + } + + long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)); + if (transcriptCount <= 0) { + meeting.setStatus(4); + meetingService.updateById(meeting); + throw new RuntimeException("鏈帴鏀跺埌鍙敤鐨勫疄鏃惰浆褰曞唴瀹?"); + } + + aiTaskService.dispatchSummaryTask(meetingId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) { + transcriptMapper.update(null, new LambdaUpdateWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .eq(MeetingTranscript::getSpeakerId, speakerId) + .set(newName != null, MeetingTranscript::getSpeakerName, newName) + .set(label != null, MeetingTranscript::getSpeakerLabel, label)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingBasic(UpdateMeetingBasicCommand command) { + meetingService.update(new LambdaUpdateWrapper() + .eq(Meeting::getId, command.getMeetingId()) + .set(command.getTitle() != null, Meeting::getTitle, command.getTitle()) + .set(command.getMeetingTime() != null, Meeting::getMeetingTime, command.getMeetingTime()) + .set(command.getTags() != null, Meeting::getTags, command.getTags())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingParticipants(Long meetingId, String participants) { + meetingService.update(new LambdaUpdateWrapper() + .eq(Meeting::getId, meetingId) + .set(Meeting::getParticipants, participants == null ? "" : participants)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSummaryContent(Long meetingId, String summaryContent) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("Meeting not found"); + } + meetingSummaryFileService.updateSummaryContent(meeting, summaryContent); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void reSummary(Long meetingId, Long summaryModelId, Long promptId) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("Meeting not found"); + } + + meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId); + meeting.setStatus(2); + meetingService.updateById(meeting); + aiTaskService.dispatchSummaryTask(meetingId); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java new file mode 100644 index 0000000..e8b380a --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -0,0 +1,198 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.event.MeetingCreatedEvent; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingSummaryFileService; +import com.imeeting.service.biz.PromptTemplateService; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MeetingDomainSupport { + + private final PromptTemplateService promptTemplateService; + private final AiTaskService aiTaskService; + private final MeetingTranscriptMapper transcriptMapper; + private final SysUserMapper sysUserMapper; + private final ApplicationEventPublisher eventPublisher; + private final MeetingSummaryFileService meetingSummaryFileService; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, + String audioUrl, Long tenantId, Long creatorId, String creatorName, int status) { + Meeting meeting = new Meeting(); + meeting.setTitle(title); + meeting.setMeetingTime(meetingTime); + meeting.setParticipants(participants); + meeting.setTags(tags); + meeting.setCreatorId(creatorId); + meeting.setCreatorName(creatorName); + meeting.setTenantId(tenantId != null ? tenantId : 0L); + meeting.setAudioUrl(audioUrl); + meeting.setStatus(status); + return meeting; + } + + public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId) { + AiTask sumTask = new AiTask(); + sumTask.setMeetingId(meetingId); + sumTask.setTaskType("SUMMARY"); + sumTask.setStatus(0); + + Map sumConfig = new HashMap<>(); + sumConfig.put("summaryModelId", summaryModelId); + if (promptId != null) { + PromptTemplate template = promptTemplateService.getById(promptId); + if (template != null) { + sumConfig.put("promptContent", template.getPromptContent()); + } + } + sumTask.setTaskConfig(sumConfig); + aiTaskService.save(sumTask); + } + + public void publishMeetingCreated(Long meetingId) { + eventPublisher.publishEvent(new MeetingCreatedEvent(meetingId)); + } + + public String relocateAudioUrl(Long meetingId, String audioUrl) { + if (audioUrl == null || audioUrl.isBlank()) { + return audioUrl; + } + if (!audioUrl.startsWith("/api/static/audio/")) { + return audioUrl; + } + + try { + String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path sourcePath = Paths.get(basePath, "audio", fileName); + if (!Files.exists(sourcePath)) { + return audioUrl; + } + + String ext = ""; + int dotIdx = fileName.lastIndexOf('.'); + if (dotIdx > 0) { + ext = fileName.substring(dotIdx); + } + Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId)); + Files.createDirectories(targetDir); + Path targetPath = targetDir.resolve("source_audio" + ext); + Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); + return "/api/static/meetings/" + meetingId + "/source_audio" + ext; + } catch (Exception ex) { + log.error("Failed to move audio file for meeting {}", meetingId, ex); + throw new RuntimeException("鏂囦欢澶勭悊澶辫触: " + ex.getMessage()); + } + } + + public String resolveSpeakerId(String speakerId) { + if (speakerId != null && !speakerId.isBlank()) { + return speakerId; + } + return "spk_0"; + } + + public String resolveSpeakerName(String speakerId, String speakerName) { + if (speakerName != null && !speakerName.isBlank()) { + return speakerName; + } + String finalSpeakerId = resolveSpeakerId(speakerId); + if (finalSpeakerId.matches("\\d+")) { + SysUser user = sysUserMapper.selectById(Long.parseLong(finalSpeakerId)); + if (user != null) { + return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); + } + } + return finalSpeakerId; + } + + public Integer resolveMeetingDuration(Long meetingId) { + MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .isNotNull(MeetingTranscript::getEndTime) + .orderByDesc(MeetingTranscript::getEndTime) + .last("LIMIT 1")); + if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) { + return latestTranscript.getEndTime(); + } + + latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .isNotNull(MeetingTranscript::getStartTime) + .orderByDesc(MeetingTranscript::getStartTime) + .last("LIMIT 1")); + if (latestTranscript != null && latestTranscript.getStartTime() != null && latestTranscript.getStartTime() > 0) { + return latestTranscript.getStartTime(); + } + return null; + } + + public void fillMeetingVO(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo, boolean includeSummary) { + 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()); + vo.setAudioUrl(meeting.getAudioUrl()); + vo.setDuration(resolveMeetingDuration(meeting.getId())); + vo.setStatus(meeting.getStatus()); + vo.setCreatedAt(meeting.getCreatedAt()); + + if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) { + try { + List userIds = Arrays.stream(meeting.getParticipants().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::valueOf) + .collect(Collectors.toList()); + vo.setParticipantIds(userIds); + if (!userIds.isEmpty()) { + List users = sysUserMapper.selectBatchIds(userIds); + String names = users.stream() + .map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()) + .collect(Collectors.joining(", ")); + vo.setParticipants(names); + } + } catch (Exception ex) { + vo.setParticipantIds(Collections.emptyList()); + vo.setParticipants(meeting.getParticipants()); + } + } else { + vo.setParticipantIds(Collections.emptyList()); + } + if (includeSummary) { + vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting)); + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java new file mode 100644 index 0000000..77e319b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java @@ -0,0 +1,307 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.biz.MeetingSummaryExportResult; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.MeetingExportService; +import com.imeeting.service.biz.MeetingSummaryFileService; +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import lombok.RequiredArgsConstructor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jsoup.Jsoup; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class MeetingExportServiceImpl implements MeetingExportService { + + private final MeetingSummaryFileService meetingSummaryFileService; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Override + public MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format) { + Path summarySourcePath = meetingSummaryFileService.requireSummarySourcePath(meeting); + String safeTitle = (meetingDetail.getTitle() == null || meetingDetail.getTitle().trim().isEmpty()) + ? "meeting-summary-" + meeting.getId() + : meetingDetail.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_"); + + String ext; + String contentType; + if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) { + ext = "docx"; + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } else if ("pdf".equalsIgnoreCase(format)) { + ext = "pdf"; + contentType = MediaType.APPLICATION_PDF_VALUE; + } else { + throw new RuntimeException("Unsupported export format"); + } + + try { + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path exportDir = Paths.get(basePath, "meetings", String.valueOf(meeting.getId()), "exports"); + Files.createDirectories(exportDir); + + Path exportPath = exportDir.resolve("summary." + ext); + boolean needRegenerate = !Files.exists(exportPath) + || Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis(); + + byte[] bytes; + if (needRegenerate) { + String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8); + meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown)); + bytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail); + Files.write(exportPath, bytes); + } else { + bytes = Files.readAllBytes(exportPath); + } + + return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "-AI-Summary." + ext); + } catch (IOException ex) { + throw new RuntimeException("Export failed: " + ex.getMessage(), ex); + } + } + + 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 Summary"); + + 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 { + Parser parser = Parser.builder().build(); + String markdown = meeting.getSummaryContent() == null ? "" : meeting.getSummaryContent(); + Node document = parser.parse(markdown); + HtmlRenderer renderer = HtmlRenderer.builder().build(); + String htmlBody = renderer.render(document); + + String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle(); + String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString(); + String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants(); + + String html = "" + + "
" + + "

" + title + "

" + + "
" + + "Meeting Time: " + time + "" + + "|" + + "Participants: " + participants + "" + + "
" + + "
" + htmlBody + "
" + + "
" + + "Generated by iMeeting AI Assistant" + + "
" + + ""; + + org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html); + jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml); + String xhtml = jsoupDoc.html(); + + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFastMode(); + + try { + java.io.InputStream simsunStream = getClass().getResourceAsStream("/fonts/simsunb.ttf"); + if (simsunStream != null) { + File tempFont = File.createTempFile("simsunb", ".ttf"); + tempFont.deleteOnExit(); + Files.copy(simsunStream, tempFont.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + builder.useFont(tempFont, "SimSun"); + simsunStream.close(); + } + + java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf"); + if (notoStream != null) { + File tempNoto = File.createTempFile("notosans", ".ttf"); + tempNoto.deleteOnExit(); + Files.copy(notoStream, tempNoto.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + builder.useFont(tempNoto, "NotoSansSC"); + notoStream.close(); + } + } catch (Exception ignored) { + } + + builder.withHtmlContent(xhtml, null); + builder.toStream(out); + builder.run(); + return out.toByteArray(); + } catch (Exception ex) { + throw new IOException("PDF generation failed", ex); + } + } + + private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) { + String input = text == null ? "" : text; + Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); + int start = 0; + while (matcher.find()) { + String normal = toPlainInline(input.substring(start, matcher.start())); + if (!normal.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(normal); + } + String boldText = toPlainInline(matcher.group(1)); + if (!boldText.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(true); + run.setFontSize(size); + run.setText(boldText); + } + start = matcher.end(); + } + String tail = toPlainInline(input.substring(start)); + if (!tail.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(tail); + } + } + + private List parseMarkdownBlocks(String markdown) { + List blocks = new ArrayList<>(); + if (markdown == null || markdown.trim().isEmpty()) { + return blocks; + } + + String[] lines = markdown.replace("\r\n", "\n").split("\n"); + StringBuilder paragraph = new StringBuilder(); + + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty()) { + flushParagraph(blocks, paragraph); + continue; + } + if (line.startsWith("#")) { + flushParagraph(blocks, paragraph); + int level = 0; + while (level < line.length() && line.charAt(level) == '#') { + level++; + } + level = Math.min(level, 6); + blocks.add(new MdBlock(MdType.HEADING, level, line.substring(level).trim())); + continue; + } + if (line.startsWith("- ") || line.startsWith("* ")) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); + continue; + } + Matcher ordered = Pattern.compile("^\\d+\\.\\s+(.*)$").matcher(line); + if (ordered.find()) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, ordered.group(1).trim())); + continue; + } + + if (paragraph.length() > 0) { + paragraph.append(' '); + } + paragraph.append(line); + } + + flushParagraph(blocks, paragraph); + return blocks; + } + + private void flushParagraph(List blocks, StringBuilder paragraph) { + if (paragraph.length() > 0) { + blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString())); + paragraph.setLength(0); + } + } + + private String toPlainInline(String input) { + if (input == null) { + return ""; + } + return input + .replaceAll("`([^`]+)`", "$1") + .replaceAll("\\*\\*(.*?)\\*\\*", "$1") + .replaceAll("\\*(.*?)\\*", "$1") + .replaceAll("\\[(.*?)]\\((.*?)\\)", "$1"); + } + + private 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; + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java new file mode 100644 index 0000000..16cecb2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java @@ -0,0 +1,130 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.imeeting.dto.biz.MeetingTranscriptVO; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingService; +import com.unisbase.dto.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MeetingQueryServiceImpl implements MeetingQueryService { + + private final MeetingService meetingService; + private final MeetingTranscriptMapper transcriptMapper; + private final MeetingDomainSupport meetingDomainSupport; + + @Override + public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, + Long userId, String userName, String viewType, boolean isAdmin) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Meeting::getTenantId, tenantId); + + if (!isAdmin || !"all".equals(viewType)) { + String userIdStr = String.valueOf(userId); + if ("created".equals(viewType)) { + wrapper.eq(Meeting::getCreatorId, userId); + } else if ("involved".equals(viewType)) { + wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)) + .ne(Meeting::getCreatorId, userId); + } else { + wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); + } + } + + if (title != null && !title.isEmpty()) { + wrapper.like(Meeting::getTitle, title); + } + + wrapper.orderByDesc(Meeting::getCreatedAt); + + Page page = meetingService.page(new Page<>(current, size), wrapper); + List vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList()); + + PageResult> result = new PageResult<>(); + result.setTotal(page.getTotal()); + result.setRecords(vos); + return result; + } + + @Override + public MeetingVO getDetail(Long id) { + Meeting meeting = meetingService.getById(id); + return meeting != null ? toVO(meeting, true) : null; + } + + @Override + public List getTranscripts(Long meetingId) { + return transcriptMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .orderByAsc(MeetingTranscript::getStartTime)) + .stream() + .map(t -> { + MeetingTranscriptVO vo = new MeetingTranscriptVO(); + vo.setId(t.getId()); + vo.setSpeakerId(t.getSpeakerId()); + vo.setSpeakerName(t.getSpeakerName()); + vo.setSpeakerLabel(t.getSpeakerLabel()); + vo.setContent(t.getContent()); + vo.setStartTime(t.getStartTime()); + vo.setEndTime(t.getEndTime()); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin) { + Map stats = new HashMap<>(); + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); + if (!isAdmin) { + String userIdStr = String.valueOf(userId); + baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); + } + + stats.put("totalMeetings", meetingService.count(baseWrapper.clone())); + stats.put("processingTasks", meetingService.count(baseWrapper.clone().in(Meeting::getStatus, 1, 2))); + LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0); + stats.put("todayNew", meetingService.count(baseWrapper.clone().ge(Meeting::getCreatedAt, todayStart))); + + long totalFinished = meetingService.count(baseWrapper.clone().in(Meeting::getStatus, 3, 4)); + long success = meetingService.count(baseWrapper.clone().eq(Meeting::getStatus, 3)); + stats.put("successRate", totalFinished == 0 ? 100 : (int) ((double) success / totalFinished * 100)); + return stats; + } + + @Override + public List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); + if (!isAdmin) { + String userIdStr = String.valueOf(userId); + wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); + } + wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit); + return meetingService.list(wrapper).stream().map(m -> toVO(m, false)).collect(Collectors.toList()); + } + + private MeetingVO toVO(Meeting meeting, boolean includeSummary) { + MeetingVO vo = new MeetingVO(); + meetingDomainSupport.fillMeetingVO(meeting, vo, includeSummary); + return vo; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index 0467d4a..e429dfd 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -1,582 +1,11 @@ package com.imeeting.service.biz.impl; -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.unisbase.dto.PageResult; -import com.imeeting.dto.biz.MeetingDTO; -import com.imeeting.dto.biz.MeetingTranscriptVO; -import com.imeeting.dto.biz.MeetingVO; -import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; -import com.imeeting.entity.biz.AiTask; -import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.Meeting; -import com.imeeting.entity.biz.MeetingTranscript; -import com.imeeting.entity.biz.PromptTemplate; -import com.imeeting.event.MeetingCreatedEvent; -import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; -import com.imeeting.mapper.biz.MeetingTranscriptMapper; -import com.imeeting.service.biz.AiModelService; -import com.imeeting.service.biz.AiTaskService; -import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.MeetingService; -import com.imeeting.service.biz.PromptTemplateService; -import com.unisbase.entity.SysUser; -import com.unisbase.mapper.SysUserMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j @Service -@RequiredArgsConstructor public class MeetingServiceImpl extends ServiceImpl implements MeetingService { - - private final AiModelService aiModelService; - private final PromptTemplateService promptTemplateService; - private final AiTaskService aiTaskService; - private final AiTaskMapper aiTaskMapper; - private final MeetingTranscriptMapper transcriptMapper; - private final HotWordService hotWordService; - private final SysUserMapper sysUserMapper; - private final ApplicationEventPublisher eventPublisher; - - @Value("${unisbase.app.upload-path}") - private String uploadPath; - - @Override - @Transactional(rollbackFor = Exception.class) - public MeetingVO createMeeting(MeetingDTO dto) { - Meeting meeting = initMeeting(dto, 0); - meeting.setAudioUrl(relocateAudioUrl(meeting.getId(), dto.getAudioUrl())); - this.updateById(meeting); - - AiTask asrTask = new AiTask(); - asrTask.setMeetingId(meeting.getId()); - asrTask.setTaskType("ASR"); - asrTask.setStatus(0); - - Map asrConfig = new HashMap<>(); - asrConfig.put("asrModelId", dto.getAsrModelId()); - asrConfig.put("useSpkId", dto.getUseSpkId() != null ? dto.getUseSpkId() : 1); - - List finalHotWords = dto.getHotWords(); - if (finalHotWords == null || finalHotWords.isEmpty()) { - finalHotWords = hotWordService.list(new LambdaQueryWrapper() - .eq(HotWord::getTenantId, meeting.getTenantId()) - .eq(HotWord::getStatus, 1)) - .stream() - .map(HotWord::getWord) - .collect(Collectors.toList()); - } - asrConfig.put("hotWords", finalHotWords); - asrTask.setTaskConfig(asrConfig); - aiTaskService.save(asrTask); - - createSummaryTask(meeting.getId(), dto.getSummaryModelId(), dto.getPromptId()); - eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId())); - return toVO(meeting, false); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public MeetingVO createRealtimeMeeting(MeetingDTO dto) { - Meeting meeting = initMeeting(dto, 1); - createSummaryTask(meeting.getId(), dto.getSummaryModelId(), dto.getPromptId()); - return toVO(meeting, false); - } - - @Override - public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(Meeting::getTenantId, tenantId); - - if (!isAdmin || !"all".equals(viewType)) { - String userIdStr = String.valueOf(userId); - if ("created".equals(viewType)) { - wrapper.eq(Meeting::getCreatorId, userId); - } else if ("involved".equals(viewType)) { - wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)) - .ne(Meeting::getCreatorId, userId); - } else { - wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) - .or() - .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); - } - } - - if (title != null && !title.isEmpty()) { - wrapper.like(Meeting::getTitle, title); - } - - wrapper.orderByDesc(Meeting::getCreatedAt); - - Page page = this.page(new Page<>(current, size), wrapper); - List vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList()); - - PageResult> result = new PageResult<>(); - result.setTotal(page.getTotal()); - result.setRecords(vos); - return result; - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteMeeting(Long id) { - this.removeById(id); - } - - @Override - public MeetingVO getDetail(Long id) { - Meeting meeting = this.getById(id); - return meeting != null ? toVO(meeting, true) : null; - } - - @Override - public List getTranscripts(Long meetingId) { - return transcriptMapper.selectList(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .orderByAsc(MeetingTranscript::getStartTime)) - .stream() - .map(t -> { - MeetingTranscriptVO vo = new MeetingTranscriptVO(); - vo.setId(t.getId()); - vo.setSpeakerId(t.getSpeakerId()); - vo.setSpeakerName(t.getSpeakerName()); - vo.setSpeakerLabel(t.getSpeakerLabel()); - vo.setContent(t.getContent()); - vo.setStartTime(t.getStartTime()); - vo.setEndTime(t.getEndTime()); - return vo; - }).collect(Collectors.toList()); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void appendRealtimeTranscripts(Long meetingId, List items) { - if (items == null || items.isEmpty()) { - return; - } - - Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .orderByDesc(MeetingTranscript::getSortOrder) - .last("LIMIT 1")) - .stream() - .findFirst() - .map(MeetingTranscript::getSortOrder) - .orElse(0); - - int nextSortOrder = maxSortOrder == null ? 0 : maxSortOrder + 1; - for (RealtimeTranscriptItemDTO item : items) { - if (item.getContent() == null || item.getContent().isBlank()) { - continue; - } - - MeetingTranscript existing = transcriptMapper.selectOne(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .eq(MeetingTranscript::getContent, item.getContent().trim()) - .eq(item.getSpeakerId() != null && !item.getSpeakerId().isBlank(), MeetingTranscript::getSpeakerId, item.getSpeakerId()) - .eq(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime()) - .eq(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime()) - .last("LIMIT 1")); - if (existing != null) { - continue; - } - - MeetingTranscript transcript = new MeetingTranscript(); - transcript.setMeetingId(meetingId); - transcript.setSpeakerId(resolveSpeakerId(item)); - transcript.setSpeakerName(resolveSpeakerName(item)); - transcript.setContent(item.getContent().trim()); - transcript.setStartTime(item.getStartTime()); - transcript.setEndTime(item.getEndTime()); - transcript.setSortOrder(nextSortOrder++); - transcriptMapper.insert(transcript); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void completeRealtimeMeeting(Long meetingId, String audioUrl) { - Meeting meeting = this.getById(meetingId); - if (meeting == null) { - throw new RuntimeException("Meeting not found"); - } - - if (audioUrl != null && !audioUrl.isBlank()) { - meeting.setAudioUrl(relocateAudioUrl(meetingId, audioUrl)); - this.updateById(meeting); - } - - long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId)); - if (transcriptCount <= 0) { - meeting.setStatus(4); - this.updateById(meeting); - throw new RuntimeException("未接收到可用的实时转录内容"); - } - - aiTaskService.dispatchSummaryTask(meetingId); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) { - transcriptMapper.update(null, new LambdaUpdateWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .eq(MeetingTranscript::getSpeakerId, speakerId) - .set(newName != null, MeetingTranscript::getSpeakerName, newName) - .set(label != null, MeetingTranscript::getSpeakerLabel, label)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateMeetingParticipants(Long meetingId, String participants) { - this.update(new LambdaUpdateWrapper() - .eq(Meeting::getId, meetingId) - .set(Meeting::getParticipants, participants == null ? "" : participants)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateSummaryContent(Long meetingId, String summaryContent) { - Meeting meeting = this.getById(meetingId); - if (meeting == null) { - throw new RuntimeException("Meeting not found"); - } - - AiTask summaryTask = findLatestSummaryTask(meeting); - if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { - throw new RuntimeException("Summary file not found"); - } - - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/")); - try { - Path parent = summaryPath.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - - String existingContent = Files.exists(summaryPath) ? Files.readString(summaryPath, StandardCharsets.UTF_8) : ""; - String frontMatter = extractFrontMatter(existingContent, meeting, summaryTask); - Files.writeString(summaryPath, frontMatter + normalizeSummaryMarkdown(summaryContent), StandardCharsets.UTF_8); - } catch (Exception e) { - throw new RuntimeException("Update summary file failed", e); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void reSummary(Long meetingId, Long summaryModelId, Long promptId) { - Meeting meeting = this.getById(meetingId); - if (meeting == null) { - throw new RuntimeException("Meeting not found"); - } - - createSummaryTask(meetingId, summaryModelId, promptId); - meeting.setStatus(2); - this.updateById(meeting); - aiTaskService.dispatchSummaryTask(meetingId); - } - - @Override - public Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin) { - Map stats = new HashMap<>(); - LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); - if (!isAdmin) { - String userIdStr = String.valueOf(userId); - baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId) - .or() - .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); - } - - stats.put("totalMeetings", this.count(baseWrapper.clone())); - stats.put("processingTasks", this.count(baseWrapper.clone().in(Meeting::getStatus, 1, 2))); - LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0); - stats.put("todayNew", this.count(baseWrapper.clone().ge(Meeting::getCreatedAt, todayStart))); - - long totalFinished = this.count(baseWrapper.clone().in(Meeting::getStatus, 3, 4)); - long success = this.count(baseWrapper.clone().eq(Meeting::getStatus, 3)); - stats.put("successRate", totalFinished == 0 ? 100 : (int) ((double) success / totalFinished * 100)); - return stats; - } - - @Override - public List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); - if (!isAdmin) { - String userIdStr = String.valueOf(userId); - wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) - .or() - .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); - } - wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit); - return this.list(wrapper).stream().map(m -> toVO(m, false)).collect(Collectors.toList()); - } - - private void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId) { - AiTask sumTask = new AiTask(); - sumTask.setMeetingId(meetingId); - sumTask.setTaskType("SUMMARY"); - sumTask.setStatus(0); - - Map sumConfig = new HashMap<>(); - sumConfig.put("summaryModelId", summaryModelId); - if (promptId != null) { - PromptTemplate template = promptTemplateService.getById(promptId); - if (template != null) { - sumConfig.put("promptContent", template.getPromptContent()); - } - } - sumTask.setTaskConfig(sumConfig); - aiTaskService.save(sumTask); - } - - private Meeting initMeeting(MeetingDTO dto, int status) { - Meeting meeting = new Meeting(); - meeting.setTitle(dto.getTitle()); - meeting.setMeetingTime(dto.getMeetingTime()); - meeting.setParticipants(dto.getParticipants()); - meeting.setTags(dto.getTags()); - meeting.setCreatorId(dto.getCreatorId()); - meeting.setCreatorName(dto.getCreatorName()); - meeting.setTenantId(dto.getTenantId() != null ? dto.getTenantId() : 0L); - meeting.setAudioUrl(dto.getAudioUrl()); - meeting.setStatus(status); - this.save(meeting); - return meeting; - } - - private String relocateAudioUrl(Long meetingId, String audioUrl) { - if (audioUrl == null || audioUrl.isBlank()) { - return audioUrl; - } - if (!audioUrl.startsWith("/api/static/audio/")) { - return audioUrl; - } - - try { - String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path sourcePath = Paths.get(basePath, "audio", fileName); - if (!Files.exists(sourcePath)) { - return audioUrl; - } - - String ext = ""; - int dotIdx = fileName.lastIndexOf('.'); - if (dotIdx > 0) { - ext = fileName.substring(dotIdx); - } - Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId)); - Files.createDirectories(targetDir); - Path targetPath = targetDir.resolve("source_audio" + ext); - Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); - return "/api/static/meetings/" + meetingId + "/source_audio" + ext; - } catch (Exception e) { - log.error("Failed to move audio file for meeting {}", meetingId, e); - throw new RuntimeException("文件处理失败: " + e.getMessage()); - } - } - - private String resolveSpeakerId(RealtimeTranscriptItemDTO item) { - if (item.getSpeakerId() != null && !item.getSpeakerId().isBlank()) { - return item.getSpeakerId(); - } - return "spk_0"; - } - - private String resolveSpeakerName(RealtimeTranscriptItemDTO item) { - if (item.getSpeakerName() != null && !item.getSpeakerName().isBlank()) { - return item.getSpeakerName(); - } - String speakerId = resolveSpeakerId(item); - if (speakerId.matches("\\d+")) { - SysUser user = sysUserMapper.selectById(Long.parseLong(speakerId)); - if (user != null) { - return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); - } - } - return speakerId; - } - - private MeetingVO toVO(Meeting meeting, boolean includeSummary) { - MeetingVO vo = new MeetingVO(); - 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()); - vo.setAudioUrl(meeting.getAudioUrl()); - vo.setDuration(resolveMeetingDuration(meeting.getId())); - vo.setStatus(meeting.getStatus()); - vo.setCreatedAt(meeting.getCreatedAt()); - - if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) { - try { - List userIds = Arrays.stream(meeting.getParticipants().split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(Long::valueOf) - .collect(Collectors.toList()); - vo.setParticipantIds(userIds); - if (!userIds.isEmpty()) { - List users = sysUserMapper.selectBatchIds(userIds); - String names = users.stream() - .map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()) - .collect(Collectors.joining(", ")); - vo.setParticipants(names); - } - } catch (Exception e) { - vo.setParticipantIds(Collections.emptyList()); - vo.setParticipants(meeting.getParticipants()); - } - } else { - vo.setParticipantIds(Collections.emptyList()); - } - if (includeSummary) { - vo.setSummaryContent(loadSummaryContent(meeting)); - } - return vo; - } - - private String loadSummaryContent(Meeting meeting) { - try { - AiTask summaryTask = findLatestSummaryTask(meeting); - if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { - return null; - } - - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/")); - if (!Files.exists(summaryPath)) { - return null; - } - - String content = Files.readString(summaryPath, StandardCharsets.UTF_8); - return stripFrontMatter(content); - } catch (Exception e) { - log.warn("Load summary content failed for meeting {}", meeting.getId(), e); - return null; - } - } - - private AiTask findLatestSummaryTask(Meeting meeting) { - AiTask summaryTask = null; - if (meeting.getLatestSummaryTaskId() != null) { - summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId()); - } - if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { - summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper() - .eq(AiTask::getMeetingId, meeting.getId()) - .eq(AiTask::getTaskType, "SUMMARY") - .eq(AiTask::getStatus, 2) - .isNotNull(AiTask::getResultFilePath) - .orderByDesc(AiTask::getId) - .last("LIMIT 1")); - } - return summaryTask; - } - - private String extractFrontMatter(String markdown, Meeting meeting, AiTask summaryTask) { - if (markdown != null && markdown.startsWith("---")) { - int second = markdown.indexOf("\n---", 3); - if (second >= 0) { - int end = second + 4; - if (end < markdown.length() && markdown.charAt(end) == '\n') { - end++; - } - return markdown.substring(0, end); - } - } - return "---\n" + - "updatedAt: " + LocalDateTime.now() + "\n" + - "meetingId: " + meeting.getId() + "\n" + - "summaryTaskId: " + summaryTask.getId() + "\n" + - "---\n\n"; - } - - private String normalizeSummaryMarkdown(String markdown) { - if (markdown == null) { - return ""; - } - String normalized = markdown.trim(); - if (!normalized.startsWith("```")) { - return normalized; - } - int firstLineEnd = normalized.indexOf('\n'); - if (firstLineEnd < 0) { - return normalized; - } - String firstLine = normalized.substring(0, firstLineEnd).trim().toLowerCase(); - if (!"```".equals(firstLine) && !"```markdown".equals(firstLine) && !"```md".equals(firstLine)) { - return normalized; - } - int lastFence = normalized.lastIndexOf("\n```"); - if (lastFence <= firstLineEnd) { - return normalized.substring(firstLineEnd + 1).trim(); - } - return normalized.substring(firstLineEnd + 1, lastFence).trim(); - } - - private Integer resolveMeetingDuration(Long meetingId) { - MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .isNotNull(MeetingTranscript::getEndTime) - .orderByDesc(MeetingTranscript::getEndTime) - .last("LIMIT 1")); - if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) { - return latestTranscript.getEndTime(); - } - - latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .isNotNull(MeetingTranscript::getStartTime) - .orderByDesc(MeetingTranscript::getStartTime) - .last("LIMIT 1")); - if (latestTranscript != null && latestTranscript.getStartTime() != null && latestTranscript.getStartTime() > 0) { - return latestTranscript.getStartTime(); - } - return null; - } - - private String stripFrontMatter(String markdown) { - if (markdown == null || markdown.isBlank()) { - return markdown; - } - if (!markdown.startsWith("---")) { - return normalizeSummaryMarkdown(markdown); - } - int second = markdown.indexOf("\n---", 3); - if (second < 0) { - return normalizeSummaryMarkdown(markdown); - } - int contentStart = second + 4; - if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') { - contentStart++; - } - return normalizeSummaryMarkdown(markdown.substring(contentStart).trim()); - } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java new file mode 100644 index 0000000..36375a1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java @@ -0,0 +1,154 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingSummaryFileService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService { + + private final AiTaskService aiTaskService; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Override + public Path requireSummarySourcePath(Meeting meeting) { + AiTask summaryTask = findLatestSummaryTask(meeting); + if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { + throw new RuntimeException("Summary file not found"); + } + + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/")); + if (!Files.exists(summaryPath)) { + throw new RuntimeException("Summary source file is missing"); + } + return summaryPath; + } + + @Override + public String loadSummaryContent(Meeting meeting) { + try { + Path summaryPath = requireSummarySourcePath(meeting); + String content = Files.readString(summaryPath, StandardCharsets.UTF_8); + return stripFrontMatter(content); + } catch (RuntimeException ex) { + return null; + } catch (Exception ex) { + throw new RuntimeException("Load summary content failed", ex); + } + } + + @Override + public void updateSummaryContent(Meeting meeting, String summaryContent) { + AiTask summaryTask = findLatestSummaryTask(meeting); + if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { + throw new RuntimeException("Summary file not found"); + } + + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/")); + try { + Path parent = summaryPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + String existingContent = Files.exists(summaryPath) ? Files.readString(summaryPath, StandardCharsets.UTF_8) : ""; + String frontMatter = extractFrontMatter(existingContent, meeting, summaryTask); + Files.writeString(summaryPath, frontMatter + normalizeSummaryMarkdown(summaryContent), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new RuntimeException("Update summary file failed", ex); + } + } + + @Override + public String stripFrontMatter(String markdown) { + if (markdown == null || markdown.isBlank()) { + return markdown; + } + if (!markdown.startsWith("---")) { + return normalizeSummaryMarkdown(markdown); + } + int second = markdown.indexOf("\n---", 3); + if (second < 0) { + return normalizeSummaryMarkdown(markdown); + } + int contentStart = second + 4; + if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') { + contentStart++; + } + return normalizeSummaryMarkdown(markdown.substring(contentStart).trim()); + } + + private AiTask findLatestSummaryTask(Meeting meeting) { + AiTask summaryTask = null; + if (meeting.getLatestSummaryTaskId() != null) { + summaryTask = aiTaskService.getById(meeting.getLatestSummaryTaskId()); + } + if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { + summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meeting.getId()) + .eq(AiTask::getTaskType, "SUMMARY") + .eq(AiTask::getStatus, 2) + .isNotNull(AiTask::getResultFilePath) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + return summaryTask; + } + + private String extractFrontMatter(String markdown, Meeting meeting, AiTask summaryTask) { + if (markdown != null && markdown.startsWith("---")) { + int second = markdown.indexOf("\n---", 3); + if (second >= 0) { + int end = second + 4; + if (end < markdown.length() && markdown.charAt(end) == '\n') { + end++; + } + return markdown.substring(0, end); + } + } + return "---\n" + + "updatedAt: " + LocalDateTime.now() + "\n" + + "meetingId: " + meeting.getId() + "\n" + + "summaryTaskId: " + summaryTask.getId() + "\n" + + "---\n\n"; + } + + private String normalizeSummaryMarkdown(String markdown) { + if (markdown == null) { + return ""; + } + String normalized = markdown.trim(); + if (!normalized.startsWith("```")) { + return normalized; + } + int firstLineEnd = normalized.indexOf('\n'); + if (firstLineEnd < 0) { + return normalized; + } + String firstLine = normalized.substring(0, firstLineEnd).trim().toLowerCase(); + if (!"```".equals(firstLine) && !"```markdown".equals(firstLine) && !"```md".equals(firstLine)) { + return normalized; + } + int lastFence = normalized.lastIndexOf("\n```"); + if (lastFence <= firstLineEnd) { + return normalized.substring(firstLineEnd + 1).trim(); + } + return normalized.substring(firstLineEnd + 1, lastFence).trim(); + } +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..7dfbbba --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,27 @@ +server: + port: ${SERVER_PORT:8081} + +spring: + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_db} + username: ${SPRING_DATASOURCE_USERNAME:postgres} + password: ${SPRING_DATASOURCE_PASSWORD:postgres} + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} + port: ${SPRING_DATA_REDIS_PORT:6379} + password: ${SPRING_DATA_REDIS_PASSWORD:} + database: ${SPRING_DATA_REDIS_DATABASE:15} + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +unisbase: + security: + jwt-secret: ${SECURITY_JWT_SECRET:change-me-dev-jwt-secret-32bytes} + internal-auth: + secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret} + app: + server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} + upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/} \ No newline at end of file diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3d14489 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,23 @@ +server: + port: ${SERVER_PORT:8080} + +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + data: + redis: + host: ${SPRING_DATA_REDIS_HOST} + port: ${SPRING_DATA_REDIS_PORT:6379} + password: ${SPRING_DATA_REDIS_PASSWORD:} + database: ${SPRING_DATA_REDIS_DATABASE:15} + +unisbase: + security: + jwt-secret: ${SECURITY_JWT_SECRET} + internal-auth: + secret: ${INTERNAL_AUTH_SECRET} + app: + server-base-url: ${APP_SERVER_BASE_URL} + upload-path: ${APP_UPLOAD_PATH} \ No newline at end of file diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index ecc4fc3..2c35f57 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -1,68 +1,27 @@ server: - port: 8080 + port: ${SERVER_PORT:8082} spring: datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.51.199:5432/imeeting_db} + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_test} username: ${SPRING_DATASOURCE_USERNAME:postgres} password: ${SPRING_DATASOURCE_PASSWORD:postgres} data: redis: - host: ${SPRING_DATA_REDIS_HOST:10.100.51.199} + host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} port: ${SPRING_DATA_REDIS_PORT:6379} - password: ${SPRING_DATA_REDIS_PASSWORD:unis@123} - database: ${SPRING_DATA_REDIS_DATABASE:15} - cache: - type: redis - servlet: - multipart: - max-file-size: 2048MB - max-request-size: 2048MB - jackson: - date-format: yyyy-MM-dd HH:mm:ss - serialization: - write-dates-as-timestamps: false - time-zone: GMT+8 + password: ${SPRING_DATA_REDIS_PASSWORD:} + database: ${SPRING_DATA_REDIS_DATABASE:16} mybatis-plus: configuration: - map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl - global-config: - db-config: - logic-delete-field: isDeleted - logic-delete-value: 1 - logic-not-delete-value: 0 unisbase: - web: - auth-endpoints-enabled: true - management-endpoints-enabled: true - tenant: - ignoreTables: - - biz_ai_tasks - - biz_meeting_transcripts - - biz_speakers security: - enabled: true - mode: embedded - jwt-secret: ${SECURITY_JWT_SECRET:change-me-please-change-me-32bytes} - auth-header: Authorization - token-prefix: "Bearer " - permit-all-urls: - - /actuator/health - - /api/static/** + jwt-secret: ${SECURITY_JWT_SECRET:change-me-test-jwt-secret-32bytes} internal-auth: - enabled: true - secret: change-me-internal-secret - header-name: X-Internal-Secret + secret: ${INTERNAL_AUTH_SECRET:change-me-test-internal-secret} app: - server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:8080} # 本地应用对外暴露的 IP 和端口 - upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/} - resource-prefix: /api/static/ - captcha: - ttl-seconds: 120 - max-attempts: 5 - token: - access-default-minutes: 30 - refresh-default-days: 7 \ No newline at end of file + server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} + upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d2a27a6..9192048 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,24 +1,15 @@ -server: - port: 8081 +server: + port: ${SERVER_PORT:8080} spring: - datasource: - url: jdbc:postgresql://10.100.51.199:5432/imeeting_db - username: postgres - password: postgres - data: - redis: - host: 10.100.51.199 - port: 6379 - password: unis@123 - database: 15 + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} cache: type: redis servlet: multipart: max-file-size: 2048MB max-request-size: 2048MB - jackson: date-format: yyyy-MM-dd HH:mm:ss serialization: @@ -28,7 +19,6 @@ spring: mybatis-plus: configuration: map-underscore-to-camel-case: true - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: isDeleted @@ -47,7 +37,6 @@ unisbase: security: enabled: true mode: embedded - jwt-secret: change-me-please-change-me-32bytes auth-header: Authorization token-prefix: "Bearer " permit-all-urls: @@ -55,11 +44,8 @@ unisbase: - /api/static/** internal-auth: enabled: true - secret: change-me-internal-secret header-name: X-Internal-Secret app: - server-base-url: http://10.100.52.13:${server.port} # 本地应用对外暴露的 IP 和端口 - upload-path: D:/data/imeeting/uploads/ resource-prefix: /api/static/ captcha: ttl-seconds: 120 diff --git a/backend/src/test/java/com/imeeting/db/DbAlterTest.java b/backend/src/test/java/com/imeeting/db/DbAlterTest.java new file mode 100644 index 0000000..86c0303 --- /dev/null +++ b/backend/src/test/java/com/imeeting/db/DbAlterTest.java @@ -0,0 +1,56 @@ +package com.imeeting.db; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest +public class DbAlterTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void testAlterTables() { + try { + jdbcTemplate.execute( + "CREATE TABLE IF NOT EXISTS biz_prompt_template_user_config (" + + "id BIGSERIAL PRIMARY KEY," + + "tenant_id BIGINT NOT NULL DEFAULT 0," + + "user_id BIGINT NOT NULL," + + "template_id BIGINT NOT NULL," + + "status SMALLINT DEFAULT 1," + + "created_at TIMESTAMP(6) NOT NULL DEFAULT now()," + + "updated_at TIMESTAMP(6) NOT NULL DEFAULT now()," + + "is_deleted SMALLINT NOT NULL DEFAULT 0" + + ")" + ); + jdbcTemplate.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uk_prompt_user_cfg_user_template " + + "ON biz_prompt_template_user_config (tenant_id, user_id, template_id) WHERE is_deleted = 0" + ); + jdbcTemplate.execute( + "CREATE INDEX IF NOT EXISTS idx_prompt_user_cfg_template " + + "ON biz_prompt_template_user_config (template_id) WHERE is_deleted = 0" + ); + + jdbcTemplate.execute("ALTER TABLE biz_ai_tasks ADD COLUMN IF NOT EXISTS task_config text"); + jdbcTemplate.execute("ALTER TABLE biz_ai_tasks ADD COLUMN IF NOT EXISTS result_file_path VARCHAR(500)"); + + jdbcTemplate.execute("ALTER TABLE biz_meetings ADD COLUMN IF NOT EXISTS latest_summary_task_id BIGINT"); + + // Drop old columns if exist + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN asr_model_id"); } catch (Exception e) {} + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN summary_model_id"); } catch (Exception e) {} + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN prompt_content"); } catch (Exception e) {} + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN use_spk_id"); } catch (Exception e) {} + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN hot_words"); } catch (Exception e) {} + try { jdbcTemplate.execute("ALTER TABLE biz_meetings DROP COLUMN summary_content"); } catch (Exception e) {} + + System.out.println("✅ Tables altered successfully"); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index cf69633..a81151c 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -17,7 +17,7 @@ export interface MeetingVO { createdAt: string; } -export interface MeetingDTO { +export interface CreateMeetingCommand { id?: number; title: string; meetingTime: string; @@ -31,6 +31,24 @@ export interface MeetingDTO { hotWords?: string[]; } +export type MeetingDTO = CreateMeetingCommand; + +export interface UpdateMeetingBasicCommand { + meetingId: number; + title?: string; + meetingTime?: string; + tags?: string; +} + +export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand; + +export interface UpdateMeetingSummaryCommand { + meetingId: number; + summaryContent: string; +} + +export type MeetingUpdateSummaryDTO = UpdateMeetingSummaryCommand; + export const getMeetingPage = (params: { current: number; size: number; @@ -43,7 +61,7 @@ export const getMeetingPage = (params: { ); }; -export const createMeeting = (data: MeetingDTO) => { +export const createMeeting = (data: CreateMeetingCommand) => { return http.post( "/api/biz/meeting", data @@ -58,7 +76,7 @@ export interface RealtimeTranscriptItemDTO { endTime?: number; } -export const createRealtimeMeeting = (data: MeetingDTO) => { +export const createRealtimeMeeting = (data: CreateMeetingCommand) => { return http.post( "/api/biz/meeting/realtime/start", data @@ -97,40 +115,67 @@ export interface MeetingTranscriptVO { export const getMeetingDetail = (id: number) => { return http.get( - `/api/biz/meeting/detail/${id}` + `/api/biz/meeting/${id}` ); }; export const getTranscripts = (id: number) => { return http.get( - `/api/biz/meeting/transcripts/${id}` + `/api/biz/meeting/${id}/transcripts` ); }; -export const updateSpeakerInfo = (params: { meetingId: number; speakerId: string; newName: string; label: string }) => { +export interface MeetingSpeakerUpdateDTO { + meetingId: number; + speakerId: string; + newName: string; + label: string; +} + +export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => { return http.put( "/api/biz/meeting/speaker", params ); }; -export const reSummary = (params: { meetingId: number; summaryModelId: number; promptId: number }) => { +export interface MeetingResummaryDTO { + meetingId: number; + summaryModelId: number; + promptId: number; +} + +export const reSummary = (params: MeetingResummaryDTO) => { return http.post( - "/api/biz/meeting/re-summary", + `/api/biz/meeting/${params.meetingId}/summary/regenerate`, params ); }; -export const updateMeeting = (data: Partial) => { +export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => { return http.put( - "/api/biz/meeting", + `/api/biz/meeting/${data.meetingId}/basic`, data ); }; -export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => { +export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => { return http.put( - "/api/biz/meeting/participants", + `/api/biz/meeting/${data.meetingId}/summary`, + data + ); +}; + +export interface UpdateMeetingParticipantsCommand { + meetingId: number; + participants: string; +} + +export type MeetingParticipantsUpdateDTO = UpdateMeetingParticipantsCommand; + +export const updateMeetingParticipants = (params: UpdateMeetingParticipantsCommand) => { + return http.put( + `/api/biz/meeting/${params.meetingId}/participants`, params ); }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 4cef054..f14b267 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -19,6 +19,7 @@ export function useAuth() { const logout = () => { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); + localStorage.removeItem("displayName"); sessionStorage.removeItem("userProfile"); setAccessToken(null); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index f2e7d72..ac2d990 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,39 +1,105 @@ -:root { +:root { --app-primary-color: #1677ff; - --app-bg-main: radial-gradient(circle at top, rgba(56, 154, 255, 0.08), transparent 26%), linear-gradient(180deg, #f3f7fb 0%, #eef3f8 100%); - --app-bg-card: rgba(255, 255, 255, 0.92); + --app-primary-rgb: 22, 119, 255; + --app-bg-main: + radial-gradient(circle at 12% 18%, rgba(136, 161, 255, 0.18), transparent 22%), + radial-gradient(circle at 84% 14%, rgba(131, 217, 255, 0.2), transparent 24%), + radial-gradient(circle at 68% 78%, rgba(255, 207, 228, 0.12), transparent 20%), + linear-gradient(180deg, #fcfdff 0%, #f6f9ff 38%, #eff4fb 100%); + --app-bg-overlay: + linear-gradient(rgba(255, 255, 255, 0.34) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.34) 1px, transparent 1px); + --app-bg-overlay-size: 36px 36px; + --app-bg-card: rgba(255, 255, 255, 0.74); --app-text-main: #1f2937; - --app-border-color: rgba(15, 93, 166, 0.06); - --app-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); + --app-border-color: rgba(103, 126, 189, 0.12); + --app-shadow: 0 18px 40px rgba(100, 118, 171, 0.1); + --app-bg-page: rgba(255, 255, 255, 0.18); + --app-bg-surface-soft: rgba(255, 255, 255, 0.56); + --app-bg-surface-strong: rgba(255, 255, 255, 0.82); + --app-text-muted: #66758f; } :root[data-theme="minimal"] { - --app-bg-main: #f9fafb; - --app-bg-card: #ffffff; + --app-bg-main: + radial-gradient(circle at 14% 18%, rgba(222, 229, 241, 0.42), transparent 20%), + linear-gradient(180deg, #fcfcfd 0%, #f5f7fb 100%); + --app-bg-overlay: + linear-gradient(rgba(255, 255, 255, 0.24) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.24) 1px, transparent 1px); + --app-bg-card: rgba(255, 255, 255, 0.82); --app-text-main: #111827; - --app-border-color: #e5e7eb; - --app-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --app-border-color: rgba(148, 163, 184, 0.16); + --app-shadow: 0 14px 32px rgba(15, 23, 42, 0.08); + --app-bg-page: rgba(255, 255, 255, 0.16); + --app-bg-surface-soft: rgba(255, 255, 255, 0.62); + --app-bg-surface-strong: rgba(255, 255, 255, 0.86); + --app-text-muted: #5b6474; } :root[data-theme="tech"] { - --app-bg-main: radial-gradient(circle at 50% 0%, rgba(22, 119, 255, 0.15), transparent 40%), #0d1117; - --app-bg-card: rgba(30, 41, 59, 0.7); + --app-bg-main: + radial-gradient(circle at 20% 20%, rgba(52, 144, 255, 0.2), transparent 18%), + radial-gradient(circle at 80% 18%, rgba(47, 211, 255, 0.14), transparent 20%), + linear-gradient(180deg, #08101c 0%, #0d1526 54%, #101b30 100%); + --app-bg-overlay: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + --app-bg-card: rgba(13, 23, 39, 0.62); --app-text-main: #e2e8f0; - --app-border-color: rgba(22, 119, 255, 0.2); - --app-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + --app-border-color: rgba(88, 151, 255, 0.18); + --app-shadow: 0 18px 44px rgba(0, 0, 0, 0.34); + --app-bg-page: rgba(5, 12, 24, 0.22); + --app-bg-surface-soft: rgba(10, 21, 37, 0.72); + --app-bg-surface-strong: rgba(8, 17, 31, 0.88); + --app-text-muted: rgba(190, 206, 229, 0.74); +} + +html { + min-height: 100%; + background: #f7faff; } body { + position: relative; margin: 0; padding: 0; + min-height: 100vh; background: var(--app-bg-main); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, - 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; color: var(--app-text-main); transition: background 0.3s ease, color 0.3s ease; } +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; +} + +body::before { + z-index: -3; + background: + radial-gradient(circle at 18% 24%, rgba(145, 167, 255, 0.22) 0%, rgba(145, 167, 255, 0) 28%), + radial-gradient(circle at 78% 18%, rgba(121, 221, 255, 0.2) 0%, rgba(121, 221, 255, 0) 24%), + radial-gradient(circle at 62% 74%, rgba(255, 209, 227, 0.16) 0%, rgba(255, 209, 227, 0) 22%); + filter: blur(6px); +} + +body::after { + inset: 18px; + z-index: -2; + border-radius: 28px; + background-image: var(--app-bg-overlay); + background-size: var(--app-bg-overlay-size); + opacity: 0.46; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.42), transparent 84%); +} + .ant-layout { background: transparent !important; } @@ -41,6 +107,7 @@ body { .ant-layout-sider { background: var(--app-bg-card) !important; border-right: 1px solid var(--app-border-color); + backdrop-filter: blur(16px); transition: background 0.3s ease; } @@ -53,31 +120,66 @@ body { color: var(--app-text-main) !important; } -/* Sider animation refinement */ .app-sider .ant-layout-sider-children { display: flex; flex-direction: column; } -/* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-thumb { - background: #ccc; + background: rgba(151, 163, 184, 0.8); border-radius: 3px; } ::-webkit-scrollbar-track { - background: #f1f1f1; + background: rgba(241, 245, 249, 0.7); } #root { + position: relative; min-height: 100vh; } +#root::before, +#root::after { + content: ""; + position: fixed; + pointer-events: none; +} + +#root::before { + inset: 0; + z-index: -1; + background: + radial-gradient(120% 48px at 8% 72%, rgba(124, 142, 255, 0.22) 0%, rgba(124, 142, 255, 0.08) 32%, rgba(124, 142, 255, 0) 58%), + radial-gradient(120% 42px at 56% 76%, rgba(96, 209, 255, 0.18) 0%, rgba(96, 209, 255, 0.08) 30%, rgba(96, 209, 255, 0) 56%), + radial-gradient(120% 54px at 90% 70%, rgba(160, 151, 255, 0.18) 0%, rgba(160, 151, 255, 0.08) 34%, rgba(160, 151, 255, 0) 60%); + background-repeat: no-repeat; + opacity: 0.9; +} + +#root::after { + left: 0; + right: 0; + bottom: 8%; + height: 180px; + z-index: -1; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.08) 100%), + repeating-linear-gradient( + 90deg, + rgba(128, 147, 255, 0.04) 0, + rgba(128, 147, 255, 0.04) 6px, + transparent 6px, + transparent 22px + ); + mask-image: radial-gradient(120% 90% at 50% 100%, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.38) 54%, transparent 82%); +} + .app-page { height: 100%; padding: 24px; @@ -98,7 +200,7 @@ body { border-radius: 16px !important; box-shadow: var(--app-shadow); background: var(--app-bg-card); - backdrop-filter: blur(10px); + backdrop-filter: blur(16px); transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; } @@ -165,17 +267,488 @@ body { display: flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.66); border-radius: 16px; - border: 1px dashed rgba(148, 163, 184, 0.5); + border: 1px dashed rgba(148, 163, 184, 0.4); + backdrop-filter: blur(12px); } .tabular-nums { font-variant-numeric: tabular-nums; } +:root[data-theme="default"] .home-landing { + --home-primary-rgb: 103, 103, 244; + --home-primary: #6767f4; + --home-title-color: #272554; + --home-body-color: #5d678c; + --home-muted-color: #9198b2; + --home-surface-strong: rgba(255, 255, 255, 0.92); + --home-surface: rgba(247, 246, 255, 0.84); + --home-surface-soft: rgba(255, 255, 255, 0.74); + --home-border-strong: rgba(214, 205, 255, 0.96); + --home-border: rgba(233, 228, 255, 0.96); + --home-shadow: 0 22px 48px rgba(141, 132, 223, 0.14); + background: + radial-gradient(circle at 14% 12%, rgba(170, 146, 255, 0.04), transparent 18%), + radial-gradient(circle at 82% 16%, rgba(165, 214, 255, 0.05), transparent 24%), + radial-gradient(circle at 62% 74%, rgba(255, 206, 232, 0.03), transparent 16%), + linear-gradient(180deg, #ffffff 0%, #ffffff 46%, #fefeff 100%) !important; +} + +:root[data-theme="default"] .home-landing__halo--large { + background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.18) 0%, rgba(var(--home-primary-rgb), 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important; +} + +:root[data-theme="default"] .home-landing__halo--small { + background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.16) 0%, rgba(var(--home-primary-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important; +} + +:root[data-theme="default"] .home-landing__eyebrow, +:root[data-theme="default"] .home-landing__status-item, +:root[data-theme="default"] .home-landing__visual-frame, +:root[data-theme="default"] .home-landing__soundstage, +:root[data-theme="default"] .home-landing__board-panel, +:root[data-theme="default"] .home-landing__board-stat, +:root[data-theme="default"] .home-recent-card { + border-color: var(--home-border) !important; + box-shadow: var(--home-shadow) !important; +} + +:root[data-theme="default"] .home-landing__eyebrow, +:root[data-theme="default"] .home-landing__status-item, +:root[data-theme="default"] .home-landing__visual-chip, +:root[data-theme="default"] .home-landing__visual-frame, +:root[data-theme="default"] .home-landing__soundstage, +:root[data-theme="default"] .home-landing__board-panel, +:root[data-theme="default"] .home-landing__board-stat, +:root[data-theme="default"] .home-recent-card, +:root[data-theme="default"] .home-landing__empty { + background: linear-gradient(180deg, var(--home-surface-strong), var(--home-surface)) !important; +} + +:root[data-theme="default"] .home-landing__eyebrow, +:root[data-theme="default"] .home-landing__visual-chip, +:root[data-theme="default"] .home-landing__board-pill, +:root[data-theme="default"] .home-entry-card__cta, +:root[data-theme="default"] .home-entry-card:hover .home-entry-card__cta { + color: var(--home-primary) !important; +} + +:root[data-theme="default"] .home-landing__title, +:root[data-theme="default"] .home-entry-card h3, +:root[data-theme="default"] .home-landing__section-head h3, +:root[data-theme="default"] .home-recent-card__head h4 { + color: var(--home-title-color) !important; +} + +:root[data-theme="default"] .home-landing__title span { + color: var(--home-primary) !important; +} + +:root[data-theme="default"] .home-landing__status-item, +:root[data-theme="default"] .home-entry-card__line, +:root[data-theme="default"] .home-recent-card__tags .ant-tag { + color: var(--home-body-color) !important; +} + +:root[data-theme="default"] .home-recent-card__foot, +:root[data-theme="default"] .home-recent-card__head .anticon { + color: var(--home-muted-color) !important; +} + +:root[data-theme="default"] .home-entry-card, +:root[data-theme="default"] .home-entry-card--violet, +:root[data-theme="default"] .home-entry-card--cyan { + border-color: var(--home-border) !important; + box-shadow: 0 18px 40px rgba(var(--home-primary-rgb), 0.14) !important; +} + +:root[data-theme="default"] .home-entry-card--violet { + background: + linear-gradient(180deg, rgba(252, 248, 255, 0.98) 0%, rgba(240, 234, 255, 0.92) 100%), + linear-gradient(135deg, rgba(212, 189, 255, 0.28), rgba(214, 228, 255, 0.12)) !important; +} + +:root[data-theme="default"] .home-entry-card--cyan { + background: + linear-gradient(180deg, rgba(244, 254, 255, 0.98) 0%, rgba(231, 249, 255, 0.92) 100%), + linear-gradient(135deg, rgba(159, 233, 255, 0.28), rgba(202, 233, 255, 0.1)) !important; +} + +:root[data-theme="default"] .home-entry-card:focus-visible { + outline-color: rgba(var(--home-primary-rgb), 0.34) !important; +} + +:root[data-theme="default"] .home-entry-card:hover, +:root[data-theme="default"] .home-recent-card:hover { + border-color: var(--home-border-strong) !important; + box-shadow: 0 24px 48px rgba(var(--home-primary-rgb), 0.18) !important; +} + +:root[data-theme="default"] .home-entry-card__icon, +:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__icon { + background: linear-gradient(135deg, rgba(var(--home-primary-rgb), 0.96) 0%, rgba(var(--home-primary-rgb), 0.48) 100%) !important; + box-shadow: 0 18px 34px rgba(var(--home-primary-rgb), 0.26) !important; +} + +:root[data-theme="default"] .home-entry-card--violet .home-entry-card__icon { + background: linear-gradient(135deg, #7569f2 0%, #9bb7ff 100%) !important; + box-shadow: 0 18px 34px rgba(112, 103, 212, 0.24) !important; +} + +:root[data-theme="default"] .home-entry-card__badge, +:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge, +:root[data-theme="default"] .home-recent-card__tags .ant-tag { + border-color: rgba(var(--home-primary-rgb), 0.18) !important; + background: rgba(var(--home-primary-rgb), 0.1) !important; +} + +:root[data-theme="default"] .home-entry-card--violet .home-entry-card__badge { + border-color: rgba(193, 176, 255, 0.24) !important; + background: rgba(193, 176, 255, 0.24) !important; + color: #695fd2 !important; +} + +:root[data-theme="default"] .home-entry-card__badge { + color: color-mix(in srgb, var(--home-primary) 72%, var(--home-title-color)) !important; +} + +:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge { + border-color: rgba(131, 220, 244, 0.22) !important; + background: rgba(131, 220, 244, 0.22) !important; + color: #3a9fc5 !important; +} + +:root[data-theme="default"] .home-entry-card__track span, +:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__track span, +:root[data-theme="default"] .home-landing__visual-waveform span, +:root[data-theme="default"] .home-landing__board-bars span, +:root[data-theme="default"] .home-landing__board-line { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(var(--home-primary-rgb), 0.62)) !important; + box-shadow: 0 8px 18px rgba(var(--home-primary-rgb), 0.18) !important; +} + +:root[data-theme="default"] .home-entry-card__pulse, +:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__pulse, +:root[data-theme="default"] .home-landing__board-glow { + background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(var(--home-primary-rgb), 0.34) 36%, rgba(var(--home-primary-rgb), 0.08) 72%, transparent 76%) !important; +} + +:root[data-theme="default"] .home-landing__visual-grid, +:root[data-theme="default"] .home-landing__board-grid { + background-image: + linear-gradient(rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px) !important; +} + +:root[data-theme="default"] .home-landing__visual-radar { + background: + radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.99) 0%, rgba(243, 244, 255, 0.94) 26%, rgba(var(--home-primary-rgb), 0.34) 48%, rgba(255, 255, 255, 0.04) 76%), + linear-gradient(145deg, rgba(var(--home-primary-rgb), 0.28), rgba(242, 246, 255, 0.12)) !important; + box-shadow: + inset 0 0 54px rgba(255, 255, 255, 0.82), + 0 18px 38px rgba(var(--home-primary-rgb), 0.14) !important; +} + +:root[data-theme="default"] .home-landing__visual-radar::before, +:root[data-theme="default"] .home-landing__visual-radar::after, +:root[data-theme="default"] .home-landing__board-node, +:root[data-theme="default"] .home-landing__board-node--active, +:root[data-theme="default"] .home-landing__board-rail { + border-color: rgba(var(--home-primary-rgb), 0.22) !important; +} + +:root[data-theme="default"] .home-landing__board-node { + background: color-mix(in srgb, var(--home-surface-strong) 92%, #ffffff) !important; +} + +:root[data-theme="default"] .home-landing__board-node--active { + border-color: rgba(var(--home-primary-rgb), 0.88) !important; + box-shadow: 0 0 0 8px rgba(var(--home-primary-rgb), 0.2) !important; +} + +:root[data-theme="default"] .home-landing__board-rail { + background: linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.16), rgba(var(--home-primary-rgb), 0.62), rgba(var(--home-primary-rgb), 0.24)) !important; +} + +:root[data-theme="default"] .home-recent-card__pin { + background: var(--home-primary) !important; + box-shadow: 0 0 0 6px rgba(var(--home-primary-rgb), 0.14) !important; +} + +:root[data-theme="default"] .home-landing__soundstage { + border-color: rgba(180, 206, 255, 0.36) !important; + background: + linear-gradient(180deg, rgba(237, 245, 255, 0.96), rgba(205, 224, 255, 0.9)), + linear-gradient(135deg, rgba(95, 138, 255, 0.3), rgba(121, 194, 255, 0.16) 56%, rgba(255, 255, 255, 0)) !important; + box-shadow: + 0 24px 52px rgba(95, 138, 255, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.92) !important; +} + +:root[data-theme="default"] .home-landing__soundstage::before { + border-color: rgba(182, 210, 255, 0.72) !important; +} + +:root[data-theme="default"] .home-landing__board-panel, +:root[data-theme="default"] .home-landing__board-stat { + border-color: rgba(160, 192, 255, 0.56) !important; + background: + linear-gradient(180deg, rgba(241, 248, 255, 0.96), rgba(215, 230, 255, 0.92)), + linear-gradient(135deg, rgba(91, 129, 240, 0.2), rgba(125, 203, 255, 0.08)) !important; + box-shadow: + 0 16px 30px rgba(89, 126, 226, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.7) !important; +} + +:root[data-theme="default"] .home-landing__board-pill { + background: linear-gradient(90deg, rgba(111, 151, 255, 0.24), rgba(150, 219, 255, 0.2)) !important; + color: #3b67d6 !important; +} + +:root[data-theme="default"] .home-landing__board-line, +:root[data-theme="default"] .home-landing__board-bars span, +:root[data-theme="default"] .home-landing__board-rail { + background: linear-gradient(90deg, rgba(74, 116, 226, 0.92), rgba(99, 161, 255, 0.62), rgba(150, 219, 255, 0.34)) !important; + box-shadow: 0 10px 18px rgba(88, 117, 214, 0.14) !important; +} + +:root[data-theme="default"] .home-landing__board-glow { + background: radial-gradient(circle, rgba(121, 175, 255, 0.52) 0%, rgba(118, 199, 255, 0.28) 42%, rgba(214, 226, 239, 0) 74%) !important; +} + +:root[data-theme="default"] .home-landing__board-node { + border-color: rgba(95, 138, 255, 0.44) !important; +} + +:root[data-theme="default"] .home-landing__board-node--active { + border-color: rgba(56, 97, 218, 0.92) !important; + box-shadow: 0 0 0 8px rgba(110, 155, 255, 0.2) !important; +} + +:root[data-theme="tech"] .home-landing { + --home-tech-rgb: var(--app-primary-rgb); + --home-tech-primary: var(--app-primary-color); + --home-tech-title: #eef4ff; + --home-tech-body: rgba(214, 225, 243, 0.84); + --home-tech-muted: rgba(167, 185, 214, 0.82); + --home-tech-surface-strong: rgba(10, 18, 32, 0.9); + --home-tech-surface: rgba(12, 22, 38, 0.76); + --home-tech-surface-soft: rgba(14, 26, 44, 0.62); + --home-tech-border: rgba(var(--home-tech-rgb), 0.22); + --home-tech-border-strong: rgba(var(--home-tech-rgb), 0.34); + --home-tech-shadow: 0 24px 58px rgba(0, 0, 0, 0.32); + background: + radial-gradient(circle at 14% 12%, rgba(var(--home-tech-rgb), 0.18), transparent 18%), + radial-gradient(circle at 82% 16%, rgba(47, 211, 255, 0.14), transparent 24%), + radial-gradient(circle at 62% 74%, rgba(var(--home-tech-rgb), 0.12), transparent 16%), + linear-gradient(180deg, #08101c 0%, #0c1527 46%, #0f1b30 100%) !important; +} + +:root[data-theme="tech"] .home-landing__halo--large { + background: radial-gradient(circle, rgba(var(--home-tech-rgb), 0.2) 0%, rgba(47, 211, 255, 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important; +} + +:root[data-theme="tech"] .home-landing__halo--small { + background: radial-gradient(circle, rgba(47, 211, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important; +} + +:root[data-theme="tech"] .home-landing__eyebrow, +:root[data-theme="tech"] .home-landing__status-item, +:root[data-theme="tech"] .home-landing__visual-frame, +:root[data-theme="tech"] .home-landing__soundstage, +:root[data-theme="tech"] .home-landing__board-panel, +:root[data-theme="tech"] .home-landing__board-stat, +:root[data-theme="tech"] .home-recent-card { + border-color: var(--home-tech-border) !important; + box-shadow: var(--home-tech-shadow) !important; +} + +:root[data-theme="tech"] .home-landing__eyebrow, +:root[data-theme="tech"] .home-landing__status-item, +:root[data-theme="tech"] .home-landing__visual-chip, +:root[data-theme="tech"] .home-landing__visual-frame, +:root[data-theme="tech"] .home-landing__soundstage, +:root[data-theme="tech"] .home-landing__board-panel, +:root[data-theme="tech"] .home-landing__board-stat, +:root[data-theme="tech"] .home-recent-card, +:root[data-theme="tech"] .home-landing__empty { + background: + linear-gradient(180deg, rgba(11, 21, 36, 0.94), rgba(14, 26, 44, 0.78)), + linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.12), rgba(47, 211, 255, 0.06)) !important; +} + +:root[data-theme="tech"] .home-landing__eyebrow, +:root[data-theme="tech"] .home-landing__visual-chip, +:root[data-theme="tech"] .home-landing__board-pill, +:root[data-theme="tech"] .home-entry-card__cta, +:root[data-theme="tech"] .home-entry-card:hover .home-entry-card__cta { + color: var(--home-tech-primary) !important; +} + +:root[data-theme="tech"] .home-landing__title, +:root[data-theme="tech"] .home-entry-card h3, +:root[data-theme="tech"] .home-landing__section-head h3, +:root[data-theme="tech"] .home-recent-card__head h4 { + color: var(--home-tech-title) !important; +} + +:root[data-theme="tech"] .home-landing__title span { + color: var(--home-tech-primary) !important; +} + +:root[data-theme="tech"] .home-landing__status-item, +:root[data-theme="tech"] .home-entry-card__line, +:root[data-theme="tech"] .home-recent-card__tags .ant-tag { + color: var(--home-tech-body) !important; +} + +:root[data-theme="tech"] .home-recent-card__foot, +:root[data-theme="tech"] .home-recent-card__head .anticon { + color: var(--home-tech-muted) !important; +} + +:root[data-theme="tech"] .home-entry-card, +:root[data-theme="tech"] .home-entry-card--violet, +:root[data-theme="tech"] .home-entry-card--cyan { + border-color: var(--home-tech-border) !important; + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28) !important; +} + +:root[data-theme="tech"] .home-entry-card--violet { + background: + linear-gradient(180deg, rgba(14, 22, 40, 0.96) 0%, rgba(22, 28, 52, 0.88) 100%), + linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.26), rgba(120, 126, 255, 0.08)) !important; +} + +:root[data-theme="tech"] .home-entry-card--cyan { + background: + linear-gradient(180deg, rgba(10, 24, 38, 0.96) 0%, rgba(14, 34, 46, 0.88) 100%), + linear-gradient(135deg, rgba(47, 211, 255, 0.22), rgba(var(--home-tech-rgb), 0.08)) !important; +} + +:root[data-theme="tech"] .home-entry-card:focus-visible { + outline-color: rgba(var(--home-tech-rgb), 0.42) !important; +} + +:root[data-theme="tech"] .home-entry-card:hover, +:root[data-theme="tech"] .home-recent-card:hover { + border-color: var(--home-tech-border-strong) !important; + box-shadow: 0 24px 52px rgba(0, 0, 0, 0.34) !important; +} + +:root[data-theme="tech"] .home-entry-card__icon { + background: linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.96) 0%, rgba(136, 178, 255, 0.62) 100%) !important; + box-shadow: 0 18px 34px rgba(var(--home-tech-rgb), 0.28) !important; +} + +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__icon { + background: linear-gradient(135deg, #1fb6d9 0%, #72dfff 100%) !important; + box-shadow: 0 18px 34px rgba(47, 211, 255, 0.24) !important; +} + +:root[data-theme="tech"] .home-entry-card__badge, +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge, +:root[data-theme="tech"] .home-recent-card__tags .ant-tag { + border-color: rgba(var(--home-tech-rgb), 0.2) !important; + background: rgba(var(--home-tech-rgb), 0.12) !important; +} + +:root[data-theme="tech"] .home-entry-card__badge { + color: rgba(188, 202, 255, 0.92) !important; +} + +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge { + border-color: rgba(47, 211, 255, 0.2) !important; + background: rgba(47, 211, 255, 0.12) !important; + color: rgba(151, 236, 255, 0.92) !important; +} + +:root[data-theme="tech"] .home-entry-card__track span, +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span, +:root[data-theme="tech"] .home-landing__visual-waveform span, +:root[data-theme="tech"] .home-landing__board-bars span, +:root[data-theme="tech"] .home-landing__board-line { + background: linear-gradient(180deg, rgba(233, 241, 255, 0.96), rgba(var(--home-tech-rgb), 0.5)) !important; + box-shadow: 0 8px 18px rgba(var(--home-tech-rgb), 0.2) !important; +} + +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span, +:root[data-theme="tech"] .home-landing__board-bars span { + background: linear-gradient(180deg, rgba(233, 247, 255, 0.92), rgba(47, 211, 255, 0.54)) !important; + box-shadow: 0 8px 18px rgba(47, 211, 255, 0.22) !important; +} + +:root[data-theme="tech"] .home-entry-card__pulse, +:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__pulse, +:root[data-theme="tech"] .home-landing__board-glow { + background: radial-gradient(circle, rgba(255, 255, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.26) 36%, rgba(var(--home-tech-rgb), 0.08) 72%, transparent 76%) !important; +} + +:root[data-theme="tech"] .home-landing__visual-grid, +:root[data-theme="tech"] .home-landing__board-grid { + background-image: + linear-gradient(rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px) !important; +} + +:root[data-theme="tech"] .home-landing__visual-radar { + background: + radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.18) 0%, rgba(88, 133, 230, 0.26) 26%, rgba(var(--home-tech-rgb), 0.28) 48%, rgba(255, 255, 255, 0.02) 76%), + linear-gradient(145deg, rgba(var(--home-tech-rgb), 0.22), rgba(47, 211, 255, 0.08)) !important; + box-shadow: + inset 0 0 54px rgba(255, 255, 255, 0.06), + 0 18px 38px rgba(0, 0, 0, 0.24) !important; +} + +:root[data-theme="tech"] .home-landing__visual-radar::before, +:root[data-theme="tech"] .home-landing__visual-radar::after, +:root[data-theme="tech"] .home-landing__board-node, +:root[data-theme="tech"] .home-landing__board-node--active, +:root[data-theme="tech"] .home-landing__board-rail { + border-color: rgba(var(--home-tech-rgb), 0.22) !important; +} + +:root[data-theme="tech"] .home-landing__board-node { + background: rgba(8, 17, 31, 0.96) !important; +} + +:root[data-theme="tech"] .home-landing__board-node--active { + border-color: rgba(var(--home-tech-rgb), 0.9) !important; + box-shadow: 0 0 0 8px rgba(var(--home-tech-rgb), 0.16) !important; +} + +:root[data-theme="tech"] .home-landing__board-rail { + background: linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.16), rgba(var(--home-tech-rgb), 0.62), rgba(47, 211, 255, 0.24)) !important; +} + +:root[data-theme="tech"] .home-recent-card__pin { + background: var(--home-tech-primary) !important; + box-shadow: 0 0 0 6px rgba(var(--home-tech-rgb), 0.14) !important; +} + @media (max-width: 768px) { + body::after { + inset: 10px; + border-radius: 18px; + opacity: 0.26; + } + + #root::before { + bottom: 4%; + height: 120px; + } + + #root::after { + height: 120px; + bottom: 5%; + } + .app-page { padding: 16px; } } + + + diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index c6109a1..3026400 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -1,4 +1,4 @@ -import * as AntIcons from "@ant-design/icons"; +import * as AntIcons from "@ant-design/icons"; import { BellOutlined, ApartmentOutlined, @@ -74,6 +74,20 @@ export default function AppLayout() { const { load: loadPermissions, can } = usePermission(); const { layoutMode } = useThemeStore(); + const currentUserDisplayName = useMemo(() => { + try { + const profileStr = sessionStorage.getItem("userProfile"); + if (profileStr) { + const profile = JSON.parse(profileStr) as { displayName?: string; username?: string }; + return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); + } + } catch { + // Ignore invalid cached profile and continue with storage fallback. + } + + return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); + }, [t]); + const fetchInitialData = useCallback(async () => { try { const storedTenants = localStorage.getItem("availableTenants"); @@ -124,6 +138,8 @@ export default function AppLayout() { const profile = await getCurrentUser(); sessionStorage.setItem("userProfile", JSON.stringify(profile)); + localStorage.setItem("displayName", profile.displayName || profile.username || ""); + localStorage.setItem("username", profile.username || localStorage.getItem("username") || ""); message.success(t("common.success")); window.location.reload(); @@ -283,7 +299,7 @@ export default function AppLayout() { } style={{ backgroundColor: "var(--app-primary-color)" }} /> - {localStorage.getItem("displayName") || t("layout.admin")} + {currentUserDisplayName} @@ -301,22 +317,33 @@ export default function AppLayout() { flexShrink: 0 }} > - logo - {(!collapsed || isTop) && ( - - {platformConfig?.projectName || "UnisBase"} - - )} + + logo + {(!collapsed || isTop) && ( + + {platformConfig?.projectName || "UnisBase"} + + )} + ); @@ -442,3 +469,5 @@ export default function AppLayout() { ); } + + diff --git a/frontend/src/pages/auth/login/index.tsx b/frontend/src/pages/auth/login/index.tsx index 1107321..52232cc 100644 --- a/frontend/src/pages/auth/login/index.tsx +++ b/frontend/src/pages/auth/login/index.tsx @@ -86,8 +86,11 @@ export default function Login() { try { const profile = await getCurrentUser(); sessionStorage.setItem("userProfile", JSON.stringify(profile)); + localStorage.setItem("displayName", profile.displayName || profile.username || values.username); + localStorage.setItem("username", profile.username || values.username); } catch { sessionStorage.removeItem("userProfile"); + localStorage.removeItem("displayName"); } message.success(t("common.success")); diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index dbfebf6..9a9db39 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -352,13 +352,13 @@ const HotWords: React.FC = () => { - - - + {/**/} + {/* */} + {/**/} diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 59ea104..3cb1b12 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Card, @@ -41,7 +41,8 @@ import { getTranscripts, updateSpeakerInfo, reSummary, - updateMeeting, + updateMeetingBasic, + updateMeetingSummary, MeetingVO, MeetingTranscriptVO, getMeetingProgress, @@ -306,9 +307,9 @@ const MeetingDetail: React.FC = () => { const vals = await form.validateFields(); setActionLoading(true); try { - await updateMeeting({ + await updateMeetingBasic({ ...vals, - id: meeting?.id, + meetingId: meeting?.id, tags: vals.tags?.join(','), }); message.success('会议信息已更新'); @@ -324,8 +325,8 @@ const MeetingDetail: React.FC = () => { const handleSaveSummary = async () => { setActionLoading(true); try { - await updateMeeting({ - id: meeting?.id, + await updateMeetingSummary({ + meetingId: meeting?.id, summaryContent: summaryDraft, }); message.success('总结内容已更新'); diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 23272e9..5c6e963 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -162,7 +162,7 @@ const MeetingCreateForm: React.FC<{ size="small" title={ 录音上传} bordered={false} - style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', background: '#f9fbff', flex: 1, display: 'flex', flexDirection: 'column' }} + style={{ borderRadius: 12, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', flex: 1, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(16px)' }} bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }} > -
form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`, backgroundColor: isSelected ? '#f0f7ff' : '#fff', cursor: 'pointer', textAlign: 'center', position: 'relative' }}> +
form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? 'var(--app-primary-color)' : 'var(--app-border-color)'}`, background: isSelected ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'var(--app-bg-surface-strong)', cursor: 'pointer', textAlign: 'center', position: 'relative' }}>
{p.templateName}
{isSelected &&
}
@@ -263,7 +263,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => return ( - navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: 'none', height: '220px', position: 'relative', boxShadow: '0 6px 16px rgba(0,0,0,0.04)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}> + navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
e.stopPropagation()}> @@ -327,7 +327,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
{item.tags?.split(',').slice(0, 2).map(t => ( - {t} + {t} ))}
@@ -441,9 +441,9 @@ const Meetings: React.FC = () => { }; return ( -
+
- +
会议中心
@@ -487,7 +487,7 @@ const Meetings: React.FC = () => { onClose={() => setCreateDrawerVisible(false)} open={createDrawerVisible} destroyOnClose - styles={{ body: { backgroundColor: '#f4f7f9', padding: '24px 32px' } }} + styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }} footer={
@@ -538,10 +538,10 @@ const Meetings: React.FC = () => {