feat: 添加实时会议和主页组件
- 新增 `RealtimeAsr` 组件,用于创建和配置实时会议 - 新增 `HomePage` 组件,展示最近的会议记录和快速入口 - 新增 `RealtimeAsrSession` 组件,用于实时会议的会中识别和转录dev_na
parent
2b1d7b8a2e
commit
653a9f7ef4
|
|
@ -129,6 +129,10 @@
|
||||||
<artifactId>unisbase-spring-boot-starter</artifactId>
|
<artifactId>unisbase-spring-boot-starter</artifactId>
|
||||||
<version>0.1.0</version>
|
<version>0.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
package com.imeeting.controller.biz;
|
package com.imeeting.controller.biz;
|
||||||
|
|
||||||
|
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
|
||||||
import com.unisbase.common.ApiResponse;
|
import com.unisbase.common.ApiResponse;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
@ -19,10 +16,10 @@ import java.util.Map;
|
||||||
@RequestMapping("/api/biz/dashboard")
|
@RequestMapping("/api/biz/dashboard")
|
||||||
public class DashboardController {
|
public class DashboardController {
|
||||||
|
|
||||||
private final MeetingService meetingService;
|
private final MeetingQueryService meetingQueryService;
|
||||||
|
|
||||||
public DashboardController(MeetingService meetingService) {
|
public DashboardController(MeetingQueryService meetingQueryService) {
|
||||||
this.meetingService = meetingService;
|
this.meetingQueryService = meetingQueryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/stats")
|
@GetMapping("/stats")
|
||||||
|
|
@ -30,7 +27,7 @@ public class DashboardController {
|
||||||
public ApiResponse<Map<String, Object>> getStats() {
|
public ApiResponse<Map<String, Object>> getStats() {
|
||||||
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
|
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")
|
@GetMapping("/recent")
|
||||||
|
|
@ -38,6 +35,6 @@ public class DashboardController {
|
||||||
public ApiResponse<List<MeetingVO>> getRecent() {
|
public ApiResponse<List<MeetingVO>> getRecent() {
|
||||||
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,13 +2,12 @@ package com.imeeting.dto.biz;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MeetingDTO {
|
public class CreateMeetingCommand {
|
||||||
private Long id;
|
|
||||||
private Long tenantId;
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|
@ -17,8 +16,6 @@ public class MeetingDTO {
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
private Long creatorId;
|
|
||||||
private String creatorName;
|
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
@ -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<String> hotWords;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class RealtimeMeetingCompleteDTO {
|
||||||
|
private String audioUrl;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UpdateMeetingParticipantsCommand {
|
||||||
|
private Long meetingId;
|
||||||
|
private String participants;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UpdateMeetingSummaryCommand {
|
||||||
|
private Long meetingId;
|
||||||
|
private String summaryContent;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<RealtimeTranscriptItemDTO> 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
||||||
|
Long userId, String userName, String viewType, boolean isAdmin);
|
||||||
|
|
||||||
|
MeetingVO getDetail(Long id);
|
||||||
|
|
||||||
|
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
||||||
|
|
||||||
|
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
||||||
|
|
||||||
|
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
||||||
|
}
|
||||||
|
|
@ -2,28 +2,7 @@ package com.imeeting.service.biz;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
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.imeeting.entity.biz.Meeting;
|
||||||
import com.unisbase.dto.PageResult;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface MeetingService extends IService<Meeting> {
|
public interface MeetingService extends IService<Meeting> {
|
||||||
MeetingVO createMeeting(MeetingDTO dto);
|
|
||||||
MeetingVO createRealtimeMeeting(MeetingDTO dto);
|
|
||||||
PageResult<List<MeetingVO>> 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<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
|
||||||
void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> 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<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
|
||||||
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, Object> asrConfig = new HashMap<>();
|
||||||
|
asrConfig.put("asrModelId", command.getAsrModelId());
|
||||||
|
asrConfig.put("useSpkId", command.getUseSpkId() != null ? command.getUseSpkId() : 1);
|
||||||
|
|
||||||
|
List<String> finalHotWords = command.getHotWords();
|
||||||
|
if (finalHotWords == null || finalHotWords.isEmpty()) {
|
||||||
|
finalHotWords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
|
||||||
|
.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<RealtimeTranscriptItemDTO> items) {
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
.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<MeetingTranscript>()
|
||||||
|
.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<MeetingTranscript>()
|
||||||
|
.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<MeetingTranscript>()
|
||||||
|
.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<Meeting>()
|
||||||
|
.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<Meeting>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, Object> 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<MeetingTranscript>()
|
||||||
|
.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<MeetingTranscript>()
|
||||||
|
.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<Long> 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<SysUser> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = "<html><head><style>" +
|
||||||
|
"body { font-family: 'NotoSansSC', 'SimSun', sans-serif; padding: 20px; line-height: 1.6; color: #333; }" +
|
||||||
|
"h1, h2, h3 { color: #1890ff; border-bottom: 1px solid #eee; padding-bottom: 5px; }" +
|
||||||
|
"table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }" +
|
||||||
|
"table, th, td { border: 1px solid #ddd; }" +
|
||||||
|
"th, td { padding: 8px 12px; text-align: left; }" +
|
||||||
|
"th { background-color: #f5f5f5; font-weight: bold; }" +
|
||||||
|
"blockquote { padding: 8px 16px; color: #666; border-left: 4px solid #1890ff; background: #f0f7ff; margin: 0 0 16px 0; }" +
|
||||||
|
"</style></head><body>" +
|
||||||
|
"<div style='text-align:center; margin-bottom:30px; border-bottom: 2px solid #1890ff; padding-bottom:20px;'>" +
|
||||||
|
"<h1 style='font-size:28px; margin-bottom:12px; color:#000; border:none;'>" + title + "</h1>" +
|
||||||
|
"<div style='font-size:14px; color:#666;'>" +
|
||||||
|
"<span>Meeting Time: " + time + "</span>" +
|
||||||
|
"<span style='margin: 0 20px;'>|</span>" +
|
||||||
|
"<span>Participants: " + participants + "</span>" +
|
||||||
|
"</div></div>" +
|
||||||
|
"<div class='markdown-body'>" + htmlBody + "</div>" +
|
||||||
|
"<div style='margin-top: 40px; text-align: right; font-size: 12px; color: #999; border-top: 1px dashed #eee; padding-top: 10px;'>" +
|
||||||
|
"Generated by iMeeting AI Assistant" +
|
||||||
|
"</div>" +
|
||||||
|
"</body></html>";
|
||||||
|
|
||||||
|
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<MdBlock> parseMarkdownBlocks(String markdown) {
|
||||||
|
List<MdBlock> blocks = new ArrayList<>();
|
||||||
|
if (markdown == null || markdown.trim().isEmpty()) {
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] lines = markdown.replace("\r\n", "\n").split("\n");
|
||||||
|
StringBuilder paragraph = new StringBuilder();
|
||||||
|
|
||||||
|
for (String raw : lines) {
|
||||||
|
String line = raw == null ? "" : raw.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
flushParagraph(blocks, paragraph);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("#")) {
|
||||||
|
flushParagraph(blocks, paragraph);
|
||||||
|
int level = 0;
|
||||||
|
while (level < line.length() && line.charAt(level) == '#') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
level = Math.min(level, 6);
|
||||||
|
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<MdBlock> blocks, StringBuilder paragraph) {
|
||||||
|
if (paragraph.length() > 0) {
|
||||||
|
blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString()));
|
||||||
|
paragraph.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toPlainInline(String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
.replaceAll("`([^`]+)`", "$1")
|
||||||
|
.replaceAll("\\*\\*(.*?)\\*\\*", "$1")
|
||||||
|
.replaceAll("\\*(.*?)\\*", "$1")
|
||||||
|
.replaceAll("\\[(.*?)]\\((.*?)\\)", "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
||||||
|
Long userId, String userName, String viewType, boolean isAdmin) {
|
||||||
|
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
|
||||||
|
.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<Meeting> page = meetingService.page(new Page<>(current, size), wrapper);
|
||||||
|
List<MeetingVO> vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList());
|
||||||
|
|
||||||
|
PageResult<List<MeetingVO>> 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<MeetingTranscriptVO> getTranscripts(Long meetingId) {
|
||||||
|
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
.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<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().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<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
|
||||||
|
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,582 +1,11 @@
|
||||||
package com.imeeting.service.biz.impl;
|
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.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.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.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.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.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
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
|
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> 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<String, Object> asrConfig = new HashMap<>();
|
|
||||||
asrConfig.put("asrModelId", dto.getAsrModelId());
|
|
||||||
asrConfig.put("useSpkId", dto.getUseSpkId() != null ? dto.getUseSpkId() : 1);
|
|
||||||
|
|
||||||
List<String> finalHotWords = dto.getHotWords();
|
|
||||||
if (finalHotWords == null || finalHotWords.isEmpty()) {
|
|
||||||
finalHotWords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
|
|
||||||
.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<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin) {
|
|
||||||
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
|
|
||||||
.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<Meeting> page = this.page(new Page<>(current, size), wrapper);
|
|
||||||
List<MeetingVO> vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList());
|
|
||||||
|
|
||||||
PageResult<List<MeetingVO>> 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<MeetingTranscriptVO> getTranscripts(Long meetingId) {
|
|
||||||
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
|
||||||
.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<RealtimeTranscriptItemDTO> items) {
|
|
||||||
if (items == null || items.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
|
||||||
.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<MeetingTranscript>()
|
|
||||||
.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<MeetingTranscript>()
|
|
||||||
.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<MeetingTranscript>()
|
|
||||||
.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<Meeting>()
|
|
||||||
.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<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
|
|
||||||
Map<String, Object> stats = new HashMap<>();
|
|
||||||
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().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<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
|
|
||||||
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().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<String, Object> 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<Long> 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<SysUser> 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<AiTask>()
|
|
||||||
.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<MeetingTranscript>()
|
|
||||||
.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<MeetingTranscript>()
|
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<AiTask>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -1,68 +1,27 @@
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: ${SERVER_PORT:8082}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
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}
|
username: ${SPRING_DATASOURCE_USERNAME:postgres}
|
||||||
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
|
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
|
||||||
data:
|
data:
|
||||||
redis:
|
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}
|
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||||
password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
|
password: ${SPRING_DATA_REDIS_PASSWORD:}
|
||||||
database: ${SPRING_DATA_REDIS_DATABASE:15}
|
database: ${SPRING_DATA_REDIS_DATABASE:16}
|
||||||
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
|
|
||||||
|
|
||||||
mybatis-plus:
|
mybatis-plus:
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
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:
|
unisbase:
|
||||||
web:
|
|
||||||
auth-endpoints-enabled: true
|
|
||||||
management-endpoints-enabled: true
|
|
||||||
tenant:
|
|
||||||
ignoreTables:
|
|
||||||
- biz_ai_tasks
|
|
||||||
- biz_meeting_transcripts
|
|
||||||
- biz_speakers
|
|
||||||
security:
|
security:
|
||||||
enabled: true
|
jwt-secret: ${SECURITY_JWT_SECRET:change-me-test-jwt-secret-32bytes}
|
||||||
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/**
|
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
secret: ${INTERNAL_AUTH_SECRET:change-me-test-internal-secret}
|
||||||
secret: change-me-internal-secret
|
|
||||||
header-name: X-Internal-Secret
|
|
||||||
app:
|
app:
|
||||||
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:8080} # 本地应用对外暴露的 IP 和端口
|
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
|
||||||
upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/}
|
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/}
|
||||||
resource-prefix: /api/static/
|
|
||||||
captcha:
|
|
||||||
ttl-seconds: 120
|
|
||||||
max-attempts: 5
|
|
||||||
token:
|
|
||||||
access-default-minutes: 30
|
|
||||||
refresh-default-days: 7
|
|
||||||
|
|
@ -1,24 +1,15 @@
|
||||||
server:
|
server:
|
||||||
port: 8081
|
port: ${SERVER_PORT:8080}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
profiles:
|
||||||
url: jdbc:postgresql://10.100.51.199:5432/imeeting_db
|
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
username: postgres
|
|
||||||
password: postgres
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: 10.100.51.199
|
|
||||||
port: 6379
|
|
||||||
password: unis@123
|
|
||||||
database: 15
|
|
||||||
cache:
|
cache:
|
||||||
type: redis
|
type: redis
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 2048MB
|
max-file-size: 2048MB
|
||||||
max-request-size: 2048MB
|
max-request-size: 2048MB
|
||||||
|
|
||||||
jackson:
|
jackson:
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
serialization:
|
serialization:
|
||||||
|
|
@ -28,7 +19,6 @@ spring:
|
||||||
mybatis-plus:
|
mybatis-plus:
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
|
||||||
global-config:
|
global-config:
|
||||||
db-config:
|
db-config:
|
||||||
logic-delete-field: isDeleted
|
logic-delete-field: isDeleted
|
||||||
|
|
@ -47,7 +37,6 @@ unisbase:
|
||||||
security:
|
security:
|
||||||
enabled: true
|
enabled: true
|
||||||
mode: embedded
|
mode: embedded
|
||||||
jwt-secret: change-me-please-change-me-32bytes
|
|
||||||
auth-header: Authorization
|
auth-header: Authorization
|
||||||
token-prefix: "Bearer "
|
token-prefix: "Bearer "
|
||||||
permit-all-urls:
|
permit-all-urls:
|
||||||
|
|
@ -55,11 +44,8 @@ unisbase:
|
||||||
- /api/static/**
|
- /api/static/**
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
secret: change-me-internal-secret
|
|
||||||
header-name: X-Internal-Secret
|
header-name: X-Internal-Secret
|
||||||
app:
|
app:
|
||||||
server-base-url: http://10.100.52.13:${server.port} # 本地应用对外暴露的 IP 和端口
|
|
||||||
upload-path: D:/data/imeeting/uploads/
|
|
||||||
resource-prefix: /api/static/
|
resource-prefix: /api/static/
|
||||||
captcha:
|
captcha:
|
||||||
ttl-seconds: 120
|
ttl-seconds: 120
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ export interface MeetingVO {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetingDTO {
|
export interface CreateMeetingCommand {
|
||||||
id?: number;
|
id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
meetingTime: string;
|
meetingTime: string;
|
||||||
|
|
@ -31,6 +31,24 @@ export interface MeetingDTO {
|
||||||
hotWords?: string[];
|
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: {
|
export const getMeetingPage = (params: {
|
||||||
current: number;
|
current: number;
|
||||||
size: number;
|
size: number;
|
||||||
|
|
@ -43,7 +61,7 @@ export const getMeetingPage = (params: {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMeeting = (data: MeetingDTO) => {
|
export const createMeeting = (data: CreateMeetingCommand) => {
|
||||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||||
"/api/biz/meeting",
|
"/api/biz/meeting",
|
||||||
data
|
data
|
||||||
|
|
@ -58,7 +76,7 @@ export interface RealtimeTranscriptItemDTO {
|
||||||
endTime?: number;
|
endTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRealtimeMeeting = (data: MeetingDTO) => {
|
export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
|
||||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||||
"/api/biz/meeting/realtime/start",
|
"/api/biz/meeting/realtime/start",
|
||||||
data
|
data
|
||||||
|
|
@ -97,40 +115,67 @@ export interface MeetingTranscriptVO {
|
||||||
|
|
||||||
export const getMeetingDetail = (id: number) => {
|
export const getMeetingDetail = (id: number) => {
|
||||||
return http.get<any, { code: string; data: MeetingVO; msg: string }>(
|
return http.get<any, { code: string; data: MeetingVO; msg: string }>(
|
||||||
`/api/biz/meeting/detail/${id}`
|
`/api/biz/meeting/${id}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTranscripts = (id: number) => {
|
export const getTranscripts = (id: number) => {
|
||||||
return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>(
|
return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>(
|
||||||
`/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<any, { code: string; data: boolean; msg: string }>(
|
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||||
"/api/biz/meeting/speaker",
|
"/api/biz/meeting/speaker",
|
||||||
params
|
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<any, { code: string; data: boolean; msg: string }>(
|
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||||
"/api/biz/meeting/re-summary",
|
`/api/biz/meeting/${params.meetingId}/summary/regenerate`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateMeeting = (data: Partial<MeetingVO>) => {
|
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
|
||||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||||
"/api/biz/meeting",
|
`/api/biz/meeting/${data.meetingId}/basic`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => {
|
export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => {
|
||||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||||
"/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<any, { code: string; data: boolean; msg: string }>(
|
||||||
|
`/api/biz/meeting/${params.meetingId}/participants`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function useAuth() {
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken");
|
||||||
|
localStorage.removeItem("displayName");
|
||||||
sessionStorage.removeItem("userProfile");
|
sessionStorage.removeItem("userProfile");
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,105 @@
|
||||||
:root {
|
:root {
|
||||||
--app-primary-color: #1677ff;
|
--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-primary-rgb: 22, 119, 255;
|
||||||
--app-bg-card: rgba(255, 255, 255, 0.92);
|
--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-text-main: #1f2937;
|
||||||
--app-border-color: rgba(15, 93, 166, 0.06);
|
--app-border-color: rgba(103, 126, 189, 0.12);
|
||||||
--app-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
--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"] {
|
:root[data-theme="minimal"] {
|
||||||
--app-bg-main: #f9fafb;
|
--app-bg-main:
|
||||||
--app-bg-card: #ffffff;
|
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-text-main: #111827;
|
||||||
--app-border-color: #e5e7eb;
|
--app-border-color: rgba(148, 163, 184, 0.16);
|
||||||
--app-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
--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"] {
|
:root[data-theme="tech"] {
|
||||||
--app-bg-main: radial-gradient(circle at 50% 0%, rgba(22, 119, 255, 0.15), transparent 40%), #0d1117;
|
--app-bg-main:
|
||||||
--app-bg-card: rgba(30, 41, 59, 0.7);
|
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-text-main: #e2e8f0;
|
||||||
--app-border-color: rgba(22, 119, 255, 0.2);
|
--app-border-color: rgba(88, 151, 255, 0.18);
|
||||||
--app-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
--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 {
|
body {
|
||||||
|
position: relative;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
background: var(--app-bg-main);
|
background: var(--app-bg-main);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
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 Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
'Noto Color Emoji';
|
'Noto Color Emoji';
|
||||||
color: var(--app-text-main);
|
color: var(--app-text-main);
|
||||||
transition: background 0.3s ease, color 0.3s ease;
|
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 {
|
.ant-layout {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +107,7 @@ body {
|
||||||
.ant-layout-sider {
|
.ant-layout-sider {
|
||||||
background: var(--app-bg-card) !important;
|
background: var(--app-bg-card) !important;
|
||||||
border-right: 1px solid var(--app-border-color);
|
border-right: 1px solid var(--app-border-color);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,31 +120,66 @@ body {
|
||||||
color: var(--app-text-main) !important;
|
color: var(--app-text-main) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sider animation refinement */
|
|
||||||
.app-sider .ant-layout-sider-children {
|
.app-sider .ant-layout-sider-children {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #ccc;
|
background: rgba(151, 163, 184, 0.8);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: rgba(241, 245, 249, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
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 {
|
.app-page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
@ -98,7 +200,7 @@ body {
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
box-shadow: var(--app-shadow);
|
box-shadow: var(--app-shadow);
|
||||||
background: var(--app-bg-card);
|
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;
|
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,17 +267,488 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(255, 255, 255, 0.78);
|
background: rgba(255, 255, 255, 0.66);
|
||||||
border-radius: 16px;
|
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 {
|
.tabular-nums {
|
||||||
font-variant-numeric: 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) {
|
@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 {
|
.app-page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import * as AntIcons from "@ant-design/icons";
|
import * as AntIcons from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
|
|
@ -74,6 +74,20 @@ export default function AppLayout() {
|
||||||
const { load: loadPermissions, can } = usePermission();
|
const { load: loadPermissions, can } = usePermission();
|
||||||
const { layoutMode } = useThemeStore();
|
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 () => {
|
const fetchInitialData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const storedTenants = localStorage.getItem("availableTenants");
|
const storedTenants = localStorage.getItem("availableTenants");
|
||||||
|
|
@ -124,6 +138,8 @@ export default function AppLayout() {
|
||||||
|
|
||||||
const profile = await getCurrentUser();
|
const profile = await getCurrentUser();
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
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"));
|
message.success(t("common.success"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|
@ -283,7 +299,7 @@ export default function AppLayout() {
|
||||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
|
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
|
||||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
|
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
|
||||||
<span style={{ fontWeight: 500 }}>{localStorage.getItem("displayName") || t("layout.admin")}</span>
|
<span style={{ fontWeight: 500 }}>{currentUserDisplayName}</span>
|
||||||
</Space>
|
</Space>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -301,22 +317,33 @@ export default function AppLayout() {
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
|
<Link
|
||||||
{(!collapsed || isTop) && (
|
to="/"
|
||||||
<span
|
style={{
|
||||||
style={{
|
display: "inline-flex",
|
||||||
fontSize: "18px",
|
alignItems: "center",
|
||||||
fontWeight: 700,
|
gap: "12px",
|
||||||
color: "var(--app-primary-color)",
|
textDecoration: "none",
|
||||||
letterSpacing: "0.5px",
|
minWidth: 0
|
||||||
overflow: "hidden",
|
}}
|
||||||
textOverflow: "ellipsis",
|
>
|
||||||
whiteSpace: "nowrap"
|
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
|
||||||
}}
|
{(!collapsed || isTop) && (
|
||||||
>
|
<span
|
||||||
{platformConfig?.projectName || "UnisBase"}
|
style={{
|
||||||
</span>
|
fontSize: "18px",
|
||||||
)}
|
fontWeight: 700,
|
||||||
|
color: "var(--app-primary-color)",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{platformConfig?.projectName || "UnisBase"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -442,3 +469,5 @@ export default function AppLayout() {
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,11 @@ export default function Login() {
|
||||||
try {
|
try {
|
||||||
const profile = await getCurrentUser();
|
const profile = await getCurrentUser();
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
|
||||||
|
localStorage.setItem("username", profile.username || values.username);
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.removeItem("userProfile");
|
sessionStorage.removeItem("userProfile");
|
||||||
|
localStorage.removeItem("displayName");
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
|
|
||||||
|
|
@ -352,13 +352,13 @@ const HotWords: React.FC = () => {
|
||||||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
{/*<Form.Item*/}
|
||||||
name="pinyin"
|
{/* name="pinyin"*/}
|
||||||
label="拼音"
|
{/* label="拼音"*/}
|
||||||
tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"
|
{/* tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"*/}
|
||||||
>
|
{/*>*/}
|
||||||
<Input placeholder="例如:hui yi" />
|
{/* <Input placeholder="例如:hui yi" />*/}
|
||||||
</Form.Item>
|
{/*</Form.Item>*/}
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
|
|
|
||||||
|
|
@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -41,7 +41,8 @@ import {
|
||||||
getTranscripts,
|
getTranscripts,
|
||||||
updateSpeakerInfo,
|
updateSpeakerInfo,
|
||||||
reSummary,
|
reSummary,
|
||||||
updateMeeting,
|
updateMeetingBasic,
|
||||||
|
updateMeetingSummary,
|
||||||
MeetingVO,
|
MeetingVO,
|
||||||
MeetingTranscriptVO,
|
MeetingTranscriptVO,
|
||||||
getMeetingProgress,
|
getMeetingProgress,
|
||||||
|
|
@ -306,9 +307,9 @@ const MeetingDetail: React.FC = () => {
|
||||||
const vals = await form.validateFields();
|
const vals = await form.validateFields();
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateMeeting({
|
await updateMeetingBasic({
|
||||||
...vals,
|
...vals,
|
||||||
id: meeting?.id,
|
meetingId: meeting?.id,
|
||||||
tags: vals.tags?.join(','),
|
tags: vals.tags?.join(','),
|
||||||
});
|
});
|
||||||
message.success('会议信息已更新');
|
message.success('会议信息已更新');
|
||||||
|
|
@ -324,8 +325,8 @@ const MeetingDetail: React.FC = () => {
|
||||||
const handleSaveSummary = async () => {
|
const handleSaveSummary = async () => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateMeeting({
|
await updateMeetingSummary({
|
||||||
id: meeting?.id,
|
meetingId: meeting?.id,
|
||||||
summaryContent: summaryDraft,
|
summaryContent: summaryDraft,
|
||||||
});
|
});
|
||||||
message.success('总结内容已更新');
|
message.success('总结内容已更新');
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ const MeetingCreateForm: React.FC<{
|
||||||
size="small"
|
size="small"
|
||||||
title={<Space><AudioOutlined /> 录音上传</Space>}
|
title={<Space><AudioOutlined /> 录音上传</Space>}
|
||||||
bordered={false}
|
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' }}
|
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
|
||||||
>
|
>
|
||||||
<Dragger
|
<Dragger
|
||||||
|
|
@ -228,7 +228,7 @@ const MeetingCreateForm: React.FC<{
|
||||||
const isSelected = watchedPromptId === p.id;
|
const isSelected = watchedPromptId === p.id;
|
||||||
return (
|
return (
|
||||||
<Col span={12} key={p.id}>
|
<Col span={12} key={p.id}>
|
||||||
<div onClick={() => 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' }}>
|
<div onClick={() => 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' }}>
|
||||||
<div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div>
|
<div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div>
|
||||||
{isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>}
|
{isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,7 +263,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item style={{ marginBottom: 24 }}>
|
<List.Item style={{ marginBottom: 24 }}>
|
||||||
<Card hoverable onClick={() => 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%' }}>
|
<Card hoverable onClick={() => 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%' }}>
|
||||||
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
||||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -327,7 +327,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>{item.tags?.split(',').slice(0, 2).map(t => (
|
<div style={{ display: 'flex', gap: 4 }}>{item.tags?.split(',').slice(0, 2).map(t => (
|
||||||
<Tag key={t} style={{ border: '1px solid #f0f0f0', backgroundColor: '#fff', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
|
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
|
||||||
))}</div>
|
))}</div>
|
||||||
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
|
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -441,9 +441,9 @@ const Meetings: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', backgroundColor: '#f4f7f9', padding: '24px', overflow: 'hidden' }}>
|
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', background: 'var(--app-bg-page)', padding: '24px', overflow: 'hidden' }}>
|
||||||
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }} bodyStyle={{ padding: '16px 28px' }}>
|
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '16px 28px' }}>
|
||||||
<Row justify="space-between" align="middle">
|
<Row justify="space-between" align="middle">
|
||||||
<Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}>会议中心</Title></Space></Col>
|
<Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}>会议中心</Title></Space></Col>
|
||||||
<Col>
|
<Col>
|
||||||
|
|
@ -487,7 +487,7 @@ const Meetings: React.FC = () => {
|
||||||
onClose={() => setCreateDrawerVisible(false)}
|
onClose={() => setCreateDrawerVisible(false)}
|
||||||
open={createDrawerVisible}
|
open={createDrawerVisible}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
styles={{ body: { backgroundColor: '#f4f7f9', padding: '24px 32px' } }}
|
styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ textAlign: 'right', padding: '12px 24px' }}>
|
<div style={{ textAlign: 'right', padding: '12px 24px' }}>
|
||||||
<Space size={12}>
|
<Space size={12}>
|
||||||
|
|
@ -538,10 +538,10 @@ const Meetings: React.FC = () => {
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; }
|
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 18px 36px rgba(0,0,0,0.16) !important; }
|
||||||
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
|
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
|
||||||
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
|
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
|
||||||
.icon-btn { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.9); display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,0.1); transition: all 0.2s; color: #8c8c8c; }
|
.icon-btn { width: 32px; height: 32px; border-radius: 50%; background: var(--app-bg-surface-strong); border: 1px solid var(--app-border-color); display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,0.1); transition: all 0.2s; color: var(--app-text-muted); }
|
||||||
.icon-btn:hover { transform: scale(1.1); }
|
.icon-btn:hover { transform: scale(1.1); }
|
||||||
.icon-btn.edit:hover { color: #1890ff; background: #e6f7ff; }
|
.icon-btn.edit:hover { color: #1890ff; background: #e6f7ff; }
|
||||||
.icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; }
|
.icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; }
|
||||||
|
|
|
||||||
|
|
@ -221,14 +221,14 @@ const PromptTemplates: React.FC = () => {
|
||||||
key={item.id}
|
key={item.id}
|
||||||
hoverable
|
hoverable
|
||||||
onClick={() => showDetail(item)}
|
onClick={() => showDetail(item)}
|
||||||
style={{ width: 320, borderRadius: 12, border: '1px solid #f0f0f0', position: 'relative', overflow: 'hidden' }}
|
style={{ width: 320, borderRadius: 12, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)', position: 'relative', overflow: 'hidden' }}
|
||||||
bodyStyle={{ padding: '24px' }}
|
bodyStyle={{ padding: '24px' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 40, height: 40, borderRadius: 10,
|
width: 40, height: 40, borderRadius: 10,
|
||||||
backgroundColor: isPlatformLevel ? '#fffbe6' : (isTenantLevel ? '#e6f7ff' : '#e6fffb'),
|
background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
|
||||||
display: 'flex', justifyContent: 'center', alignItems: 'center'
|
display: 'flex', justifyContent: 'center', alignItems: 'center'
|
||||||
}}>
|
}}>
|
||||||
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
|
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
|
||||||
|
|
@ -255,7 +255,7 @@ const PromptTemplates: React.FC = () => {
|
||||||
{item.tags?.map(tag => {
|
{item.tags?.map(tag => {
|
||||||
const dictItem = dictTags.find(dt => dt.itemValue === tag);
|
const dictItem = dictTags.find(dt => dt.itemValue === tag);
|
||||||
return (
|
return (
|
||||||
<Tag key={tag} style={{ margin: 0, border: 'none', backgroundColor: '#f0f2f5', color: '#595959', borderRadius: 4, fontSize: 10 }}>
|
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10 }}>
|
||||||
{dictItem ? dictItem.itemLabel : tag}
|
{dictItem ? dictItem.itemLabel : tag}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
|
|
@ -286,7 +286,7 @@ const PromptTemplates: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '32px', backgroundColor: '#fff', minHeight: '100%', overflowY: 'auto' }}>
|
<div style={{ padding: '32px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
|
||||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
|
||||||
<Title level={3} style={{ margin: 0 }}>提示词模板</Title>
|
<Title level={3} style={{ margin: 0 }}>提示词模板</Title>
|
||||||
|
|
@ -295,7 +295,7 @@ const PromptTemplates: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card bordered={false} bodyStyle={{ padding: '20px 24px', backgroundColor: '#f9f9f9', borderRadius: 12, marginBottom: 32 }}>
|
<Card bordered={false} style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '20px 24px' }}>
|
||||||
<Form form={searchForm} layout="inline" onFinish={fetchData}>
|
<Form form={searchForm} layout="inline" onFinish={fetchData}>
|
||||||
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
|
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
|
||||||
<Form.Item name="category" label="分类">
|
<Form.Item name="category" label="分类">
|
||||||
|
|
@ -418,7 +418,7 @@ const PromptTemplates: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 8, padding: '16px 24px' }}>
|
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', borderRadius: 8, padding: '16px 24px' }}>
|
||||||
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
|
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
AudioOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PageHeader from "../../components/shared/PageHeader";
|
||||||
|
import { listUsers } from "../../api";
|
||||||
|
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
|
||||||
|
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
|
||||||
|
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
||||||
|
import { createRealtimeMeeting, type MeetingDTO } from "../../api/business/meeting";
|
||||||
|
import type { SysUser } from "../../types";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
type RealtimeMeetingSessionDraft = {
|
||||||
|
meetingId: number;
|
||||||
|
meetingTitle: string;
|
||||||
|
asrModelName: string;
|
||||||
|
summaryModelName: string;
|
||||||
|
wsUrl: string;
|
||||||
|
mode: string;
|
||||||
|
useSpkId: number;
|
||||||
|
hotwords: Array<{ hotword: string; weight: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveWsUrl(model?: AiModelVO | null) {
|
||||||
|
if (model?.wsUrl) {
|
||||||
|
return model.wsUrl;
|
||||||
|
}
|
||||||
|
if (model?.baseUrl) {
|
||||||
|
return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionKey(meetingId: number) {
|
||||||
|
return `realtimeMeetingSession:${meetingId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealtimeAsr() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
||||||
|
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||||
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||||
|
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||||
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
|
|
||||||
|
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
||||||
|
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
||||||
|
const watchedHotWords = Form.useWatch("hotWords", form) || [];
|
||||||
|
const watchedParticipants = Form.useWatch("participants", form) || [];
|
||||||
|
const watchedUseSpkId = Form.useWatch("useSpkId", form);
|
||||||
|
|
||||||
|
const selectedAsrModel = useMemo(
|
||||||
|
() => asrModels.find((item) => item.id === watchedAsrModelId) || null,
|
||||||
|
[asrModels, watchedAsrModelId],
|
||||||
|
);
|
||||||
|
const selectedSummaryModel = useMemo(
|
||||||
|
() => llmModels.find((item) => item.id === watchedSummaryModelId) || null,
|
||||||
|
[llmModels, watchedSummaryModelId],
|
||||||
|
);
|
||||||
|
const selectedHotwordCount = watchedHotWords.length > 0 ? watchedHotWords.length : hotwordList.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
||||||
|
getAiModelPage({ current: 1, size: 100, type: "ASR" }),
|
||||||
|
getAiModelPage({ current: 1, size: 100, type: "LLM" }),
|
||||||
|
getPromptPage({ current: 1, size: 100 }),
|
||||||
|
getHotWordPage({ current: 1, size: 1000 }),
|
||||||
|
listUsers(),
|
||||||
|
getAiModelDefault("ASR"),
|
||||||
|
getAiModelDefault("LLM"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeAsrModels = asrRes.data.data.records.filter((item) => item.status === 1);
|
||||||
|
const activeLlmModels = llmRes.data.data.records.filter((item) => item.status === 1);
|
||||||
|
const activePrompts = promptRes.data.data.records.filter((item) => item.status === 1);
|
||||||
|
const activeHotwords = hotwordRes.data.data.records.filter((item) => item.status === 1);
|
||||||
|
|
||||||
|
setAsrModels(activeAsrModels);
|
||||||
|
setLlmModels(activeLlmModels);
|
||||||
|
setPrompts(activePrompts);
|
||||||
|
setHotwordList(activeHotwords);
|
||||||
|
setUserList(users || []);
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
||||||
|
meetingTime: dayjs(),
|
||||||
|
asrModelId: defaultAsr.data.data?.id,
|
||||||
|
summaryModelId: defaultLlm.data.data?.id,
|
||||||
|
promptId: activePrompts[0]?.id,
|
||||||
|
useSpkId: 1,
|
||||||
|
mode: "2pass",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
message.error("加载实时会议配置失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadInitialData();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const wsUrl = resolveWsUrl(selectedAsrModel);
|
||||||
|
if (!wsUrl) {
|
||||||
|
message.error("当前 ASR 模型没有配置 WebSocket 地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const selectedHotwords = (values.hotWords?.length
|
||||||
|
? hotwordList.filter((item) => values.hotWords.includes(item.word))
|
||||||
|
: hotwordList
|
||||||
|
).map((item) => ({
|
||||||
|
hotword: item.word,
|
||||||
|
weight: Number(item.weight || 2) / 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payload: MeetingDTO = {
|
||||||
|
...values,
|
||||||
|
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||||
|
participants: values.participants?.join(",") || "",
|
||||||
|
tags: values.tags?.join(",") || "",
|
||||||
|
useSpkId: values.useSpkId ? 1 : 0,
|
||||||
|
hotWords: values.hotWords,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await createRealtimeMeeting(payload);
|
||||||
|
const createdMeeting = res.data.data;
|
||||||
|
const sessionDraft: RealtimeMeetingSessionDraft = {
|
||||||
|
meetingId: createdMeeting.id,
|
||||||
|
meetingTitle: createdMeeting.title,
|
||||||
|
asrModelName: selectedAsrModel?.modelName || "ASR",
|
||||||
|
summaryModelName: selectedSummaryModel?.modelName || "LLM",
|
||||||
|
wsUrl,
|
||||||
|
mode: values.mode || "2pass",
|
||||||
|
useSpkId: values.useSpkId ? 1 : 0,
|
||||||
|
hotwords: selectedHotwords,
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
|
||||||
|
message.success("会议已创建,进入实时识别");
|
||||||
|
navigate(`/meeting-live-session/${createdMeeting.id}`);
|
||||||
|
} catch {
|
||||||
|
message.error("创建实时会议失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
<PageHeader
|
||||||
|
title="实时识别会议"
|
||||||
|
subtitle="先配置再进入会中识别,减少会中页面干扰。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||||
|
{loading ? (
|
||||||
|
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||||
|
<div style={{ textAlign: "center", padding: "88px 0" }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Row gutter={16} style={{ height: "100%" }}>
|
||||||
|
<Col xs={24} xl={17} style={{ height: "100%" }}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||||
|
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12, padding: 14, borderRadius: 16, background: "linear-gradient(135deg, #f8fbff 0%, #eef6ff 58%, #ffffff 100%)", border: "1px solid #dbeafe", flexShrink: 0 }}>
|
||||||
|
<Space direction="vertical" size={6}>
|
||||||
|
<Space size={10}>
|
||||||
|
<div style={{ width: 42, height: 42, borderRadius: 12, background: "#1677ff", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<AudioOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>创建实时会议</Title>
|
||||||
|
<Text type="secondary">完成会前配置后再进入会中识别页。</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Space wrap size={[8, 8]}>
|
||||||
|
<Tag color="blue">单页配置</Tag>
|
||||||
|
<Tag color="cyan">会中专注转写</Tag>
|
||||||
|
<Tag color="gold">异常关闭自动兜底</Tag>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" initialValues={{ mode: "2pass", useSpkId: 1 }} style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="title" label="会议标题" rules={[{ required: true, message: "请输入会议标题" }]}>
|
||||||
|
<Input placeholder="例如:产品例会实时记录" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true, message: "请选择会议时间" }]}>
|
||||||
|
<DatePicker showTime style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="participants" label="参会人员">
|
||||||
|
<Select mode="multiple" showSearch optionFilterProp="children" placeholder="选择参会人员">
|
||||||
|
{userList.map((user) => (
|
||||||
|
<Option key={user.userId} value={user.userId}>
|
||||||
|
<Space>
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} />
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="tags" label="会议标签">
|
||||||
|
<Select mode="tags" placeholder="输入标签后回车" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="asrModelId" label="识别模型 (ASR)" rules={[{ required: true, message: "请选择 ASR 模型" }]}>
|
||||||
|
<Select placeholder="选择实时识别模型">
|
||||||
|
{asrModels.map((model) => (
|
||||||
|
<Option key={model.id} value={model.id}>{model.modelName}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true, message: "请选择总结模型" }]}>
|
||||||
|
<Select placeholder="选择总结模型">
|
||||||
|
{llmModels.map((model) => (
|
||||||
|
<Option key={model.id} value={model.id}>{model.modelName}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="promptId" label="总结模板" rules={[{ required: true, message: "请选择总结模板" }]}>
|
||||||
|
<Select placeholder="选择总结模板">
|
||||||
|
{prompts.map((prompt) => (
|
||||||
|
<Option key={prompt.id} value={prompt.id}>{prompt.templateName}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="hotWords" label={<span>热词增强 <Tooltip title="不选择时将带上系统当前启用的热词"><QuestionCircleOutlined /></Tooltip></span>}>
|
||||||
|
<Select mode="multiple" allowClear placeholder="可选热词">
|
||||||
|
{hotwordList.map((item) => (
|
||||||
|
<Option key={item.word} value={item.word}>{item.word}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="mode" label="识别模式">
|
||||||
|
<Select>
|
||||||
|
<Option value="2pass">2pass</Option>
|
||||||
|
<Option value="online">online</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item
|
||||||
|
name="useSpkId"
|
||||||
|
label={<span>说话人区分 <Tooltip title="开启后会尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
|
||||||
|
valuePropName="checked"
|
||||||
|
getValueProps={(value) => ({ checked: value === 1 || value === true })}
|
||||||
|
normalize={(value) => (value ? 1 : 0)}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item label="WebSocket 地址">
|
||||||
|
<Input value={resolveWsUrl(selectedAsrModel)} prefix={<LinkOutlined />} readOnly />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} xl={7} style={{ height: "100%" }}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||||
|
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||||
|
<Col span={12}><Statistic title="参会人数" value={watchedParticipants.length} prefix={<TeamOutlined />} /></Col>
|
||||||
|
<Col span={12}><Statistic title="热词数量" value={selectedHotwordCount} prefix={<MessageOutlined />} /></Col>
|
||||||
|
<Col span={12}><Statistic title="说话人区分" value={watchedUseSpkId ? "开启" : "关闭"} prefix={<CheckCircleOutlined />} /></Col>
|
||||||
|
<Col span={12}><Statistic title="识别链路" value={selectedAsrModel ? "已就绪" : "待配置"} prefix={<AudioOutlined />} /></Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Space direction="vertical" size={12} style={{ width: "100%", flex: 1, minHeight: 0 }}>
|
||||||
|
<div><Text strong style={{ fontSize: 15 }}>本次识别摘要</Text></div>
|
||||||
|
<div style={{ padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||||
|
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">ASR</Text><Text strong>{selectedAsrModel?.modelName || "-"}</Text></div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">LLM</Text><Text strong>{selectedSummaryModel?.modelName || "-"}</Text></div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">WebSocket</Text><Text ellipsis style={{ maxWidth: 220 }}>{resolveWsUrl(selectedAsrModel) || "-"}</Text></div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Alert type="info" showIcon message="异常关闭保护" description="会中页持续写库并在关闭时自动兜底结束。" />
|
||||||
|
<div style={{ marginTop: "auto", padding: 12, borderRadius: 14, background: "#f6ffed", border: "1px solid #b7eb8f" }}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 10 }}>
|
||||||
|
<Text type="secondary">创建成功后会直接进入识别页,不会在当前页面占用麦克风。</Text>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => navigate("/meetings")}>返回会议中心</Button>
|
||||||
|
<Button type="primary" icon={<RocketOutlined />} loading={submitting} onClick={() => void handleCreate()}>
|
||||||
|
创建并进入识别
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,633 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
AudioOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PageHeader from "../../components/shared/PageHeader";
|
||||||
|
import {
|
||||||
|
appendRealtimeTranscripts,
|
||||||
|
completeRealtimeMeeting,
|
||||||
|
getMeetingDetail,
|
||||||
|
getTranscripts,
|
||||||
|
uploadAudio,
|
||||||
|
type MeetingTranscriptVO,
|
||||||
|
type MeetingVO,
|
||||||
|
type RealtimeTranscriptItemDTO,
|
||||||
|
} from "../../api/business/meeting";
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const SAMPLE_RATE = 16000;
|
||||||
|
const CHUNK_SIZE = 1280;
|
||||||
|
|
||||||
|
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
|
||||||
|
type WsMessage = {
|
||||||
|
text?: string;
|
||||||
|
is_final?: boolean;
|
||||||
|
speaker?: WsSpeaker;
|
||||||
|
timestamp?: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TranscriptCard = {
|
||||||
|
id: string;
|
||||||
|
speakerName: string;
|
||||||
|
userId?: string | number;
|
||||||
|
text: string;
|
||||||
|
startTime?: number;
|
||||||
|
endTime?: number;
|
||||||
|
final: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RealtimeMeetingSessionDraft = {
|
||||||
|
meetingId: number;
|
||||||
|
meetingTitle: string;
|
||||||
|
asrModelName: string;
|
||||||
|
summaryModelName: string;
|
||||||
|
wsUrl: string;
|
||||||
|
mode: string;
|
||||||
|
useSpkId: number;
|
||||||
|
hotwords: Array<{ hotword: string; weight: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSessionKey(meetingId: number) {
|
||||||
|
return `realtimeMeetingSession:${meetingId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWavBlob(samples: number[], sampleRate: number) {
|
||||||
|
const pcmBuffer = new ArrayBuffer(samples.length * 2);
|
||||||
|
const pcmView = new DataView(pcmBuffer);
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
const value = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
pcmView.setInt16(i * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavBuffer = new ArrayBuffer(44 + pcmBuffer.byteLength);
|
||||||
|
const wavView = new DataView(wavBuffer);
|
||||||
|
const writeString = (offset: number, text: string) => {
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
wavView.setUint8(offset + i, text.charCodeAt(i));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
writeString(0, "RIFF");
|
||||||
|
wavView.setUint32(4, 36 + pcmBuffer.byteLength, true);
|
||||||
|
writeString(8, "WAVE");
|
||||||
|
writeString(12, "fmt ");
|
||||||
|
wavView.setUint32(16, 16, true);
|
||||||
|
wavView.setUint16(20, 1, true);
|
||||||
|
wavView.setUint16(22, 1, true);
|
||||||
|
wavView.setUint32(24, sampleRate, true);
|
||||||
|
wavView.setUint32(28, sampleRate * 2, true);
|
||||||
|
wavView.setUint16(32, 2, true);
|
||||||
|
wavView.setUint16(34, 16, true);
|
||||||
|
writeString(36, "data");
|
||||||
|
wavView.setUint32(40, pcmBuffer.byteLength, true);
|
||||||
|
new Uint8Array(wavBuffer, 44).set(new Uint8Array(pcmBuffer));
|
||||||
|
|
||||||
|
return new Blob([wavBuffer], { type: "audio/wav" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatTo16BitPCM(input: Float32Array) {
|
||||||
|
const buffer = new ArrayBuffer(input.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
const value = Math.max(-1, Math.min(1, input[i]));
|
||||||
|
view.setInt16(i * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSpeaker(speaker?: WsSpeaker) {
|
||||||
|
if (!speaker) {
|
||||||
|
return { speakerId: "spk_0", speakerName: "Unknown", userId: undefined };
|
||||||
|
}
|
||||||
|
if (typeof speaker === "string") {
|
||||||
|
return { speakerId: speaker, speakerName: speaker, userId: undefined };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
speakerId: speaker.user_id ? String(speaker.user_id) : "spk_0",
|
||||||
|
speakerName: speaker.name || (speaker.user_id ? String(speaker.user_id) : "Unknown"),
|
||||||
|
userId: speaker.user_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(totalSeconds: number) {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTranscriptTime(ms?: number) {
|
||||||
|
if (ms === undefined || ms === null) {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealtimeAsrSession() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const meetingId = Number(id);
|
||||||
|
|
||||||
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
|
const [sessionDraft, setSessionDraft] = useState<RealtimeMeetingSessionDraft | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
const [statusText, setStatusText] = useState("待开始");
|
||||||
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
const [streamingSpeaker, setStreamingSpeaker] = useState("Unknown");
|
||||||
|
const [transcripts, setTranscripts] = useState<TranscriptCard[]>([]);
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const audioBufferRef = useRef<number[]>([]);
|
||||||
|
const recordedSamplesRef = useRef<number[]>([]);
|
||||||
|
const completeOnceRef = useRef(false);
|
||||||
|
const startedAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const finalTranscriptCount = transcripts.length;
|
||||||
|
const totalTranscriptChars = useMemo(
|
||||||
|
() => transcripts.reduce((sum, item) => sum + item.text.length, 0) + streamingText.length,
|
||||||
|
[streamingText, transcripts],
|
||||||
|
);
|
||||||
|
const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meetingId || Number.isNaN(meetingId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(getSessionKey(meetingId));
|
||||||
|
setSessionDraft(stored ? JSON.parse(stored) : null);
|
||||||
|
|
||||||
|
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
||||||
|
setMeeting(detailRes.data.data);
|
||||||
|
setTranscripts(
|
||||||
|
(transcriptRes.data.data || []).map((item: MeetingTranscriptVO) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
speakerName: item.speakerName || item.speakerId || "发言人",
|
||||||
|
text: item.content,
|
||||||
|
startTime: item.startTime,
|
||||||
|
endTime: item.endTime,
|
||||||
|
final: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
message.error("加载实时会议失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadData();
|
||||||
|
}, [meetingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) {
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (startedAtRef.current) {
|
||||||
|
setElapsedSeconds(Math.floor((Date.now() - startedAtRef.current) / 1000));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!transcriptRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||||
|
}, [streamingText, transcripts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageHide = () => {
|
||||||
|
if (!meetingId || completeOnceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
completeOnceRef.current = true;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ is_speaking: false }));
|
||||||
|
}
|
||||||
|
fetch(`/api/biz/meeting/${meetingId}/realtime/complete`, {
|
||||||
|
method: "POST",
|
||||||
|
keepalive: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", handlePageHide);
|
||||||
|
return () => window.removeEventListener("pagehide", handlePageHide);
|
||||||
|
}, [meetingId]);
|
||||||
|
|
||||||
|
const shutdownAudioPipeline = async () => {
|
||||||
|
processorRef.current?.disconnect();
|
||||||
|
audioSourceRef.current?.disconnect();
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
|
||||||
|
await audioContextRef.current.close();
|
||||||
|
}
|
||||||
|
streamRef.current = null;
|
||||||
|
processorRef.current = null;
|
||||||
|
audioSourceRef.current = null;
|
||||||
|
audioContextRef.current = null;
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
const recordedSamples = recordedSamplesRef.current;
|
||||||
|
recordedSamplesRef.current = [];
|
||||||
|
setAudioLevel(0);
|
||||||
|
return recordedSamples.length > 0 ? buildWavBlob(recordedSamples, SAMPLE_RATE) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAudioPipeline = async () => {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
audioSourceRef.current = source;
|
||||||
|
processorRef.current = processor;
|
||||||
|
recordedSamplesRef.current = [];
|
||||||
|
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
const input = event.inputBuffer.getChannelData(0);
|
||||||
|
let maxAmplitude = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
const amplitude = Math.abs(input[i]);
|
||||||
|
if (amplitude > maxAmplitude) {
|
||||||
|
maxAmplitude = amplitude;
|
||||||
|
}
|
||||||
|
audioBufferRef.current.push(input[i]);
|
||||||
|
recordedSamplesRef.current.push(input[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioLevel(Math.min(100, Math.round(maxAmplitude * 180)));
|
||||||
|
|
||||||
|
while (audioBufferRef.current.length >= CHUNK_SIZE) {
|
||||||
|
const chunk = audioBufferRef.current.slice(0, CHUNK_SIZE);
|
||||||
|
audioBufferRef.current = audioBufferRef.current.slice(CHUNK_SIZE);
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(floatTo16BitPCM(new Float32Array(chunk)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFinalTranscript = async (msg: WsMessage) => {
|
||||||
|
if (!msg.text || !meetingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const speaker = resolveSpeaker(msg.speaker);
|
||||||
|
const item: RealtimeTranscriptItemDTO = {
|
||||||
|
speakerId: speaker.speakerId,
|
||||||
|
speakerName: speaker.speakerName,
|
||||||
|
content: msg.text,
|
||||||
|
startTime: msg.timestamp?.[0]?.[0],
|
||||||
|
endTime: msg.timestamp?.[msg.timestamp.length - 1]?.[1],
|
||||||
|
};
|
||||||
|
await appendRealtimeTranscripts(meetingId, [item]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!sessionDraft?.wsUrl) {
|
||||||
|
message.error("未找到实时识别配置,请返回创建页重新进入");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recording || connecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnecting(true);
|
||||||
|
setStatusText("连接识别服务...");
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(sessionDraft.wsUrl);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
wsRef.current = socket;
|
||||||
|
|
||||||
|
socket.onopen = async () => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
mode: sessionDraft.mode || "2pass",
|
||||||
|
chunk_size: [0, 8, 4],
|
||||||
|
chunk_interval: 4,
|
||||||
|
wav_name: `meeting_${meetingId}`,
|
||||||
|
is_speaking: true,
|
||||||
|
speaker_name: null,
|
||||||
|
use_spk_id: sessionDraft.useSpkId,
|
||||||
|
save_audio: false,
|
||||||
|
hotwords: sessionDraft.hotwords,
|
||||||
|
}));
|
||||||
|
await startAudioPipeline();
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
|
setConnecting(false);
|
||||||
|
setRecording(true);
|
||||||
|
setStatusText("实时识别中");
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as WsMessage;
|
||||||
|
if (!payload.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speaker = resolveSpeaker(payload.speaker);
|
||||||
|
if (payload.is_final) {
|
||||||
|
setTranscripts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `${Date.now()}-${Math.random()}`,
|
||||||
|
speakerName: speaker.speakerName,
|
||||||
|
userId: speaker.userId,
|
||||||
|
text: payload.text,
|
||||||
|
startTime: payload.timestamp?.[0]?.[0],
|
||||||
|
endTime: payload.timestamp?.[payload.timestamp.length - 1]?.[1],
|
||||||
|
final: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingSpeaker("Unknown");
|
||||||
|
void saveFinalTranscript(payload);
|
||||||
|
} else {
|
||||||
|
setStreamingText(payload.text);
|
||||||
|
setStreamingSpeaker(speaker.speakerName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid payload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
setConnecting(false);
|
||||||
|
setRecording(false);
|
||||||
|
setStatusText("连接失败");
|
||||||
|
message.error("实时识别 WebSocket 连接失败");
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
setConnecting(false);
|
||||||
|
setRecording(false);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
setConnecting(false);
|
||||||
|
setStatusText("启动失败");
|
||||||
|
message.error("启动实时识别失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (navigateAfterStop = true) => {
|
||||||
|
if (!meetingId || completeOnceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeOnceRef.current = true;
|
||||||
|
setFinishing(true);
|
||||||
|
setStatusText("结束会议中...");
|
||||||
|
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ is_speaking: false }));
|
||||||
|
}
|
||||||
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
const audioBlob = await shutdownAudioPipeline();
|
||||||
|
let uploadedAudioUrl: string | undefined;
|
||||||
|
if (audioBlob) {
|
||||||
|
try {
|
||||||
|
const file = new File([audioBlob], `meeting-${meetingId}.wav`, { type: audioBlob.type || "audio/wav" });
|
||||||
|
const uploadRes = await uploadAudio(file);
|
||||||
|
uploadedAudioUrl = uploadRes.data.data;
|
||||||
|
} catch {
|
||||||
|
message.warning("会议音频上传失败,已保留转录内容");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeRealtimeMeeting(meetingId, uploadedAudioUrl ? { audioUrl: uploadedAudioUrl } : {});
|
||||||
|
sessionStorage.removeItem(getSessionKey(meetingId));
|
||||||
|
setStatusText("已提交总结任务");
|
||||||
|
message.success("实时会议已结束,正在生成总结");
|
||||||
|
if (navigateAfterStop) {
|
||||||
|
navigate(`/meetings/${meetingId}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completeOnceRef.current = false;
|
||||||
|
setStatusText("结束失败");
|
||||||
|
} finally {
|
||||||
|
setRecording(false);
|
||||||
|
setFinishing(false);
|
||||||
|
startedAtRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||||
|
<div style={{ textAlign: "center", padding: "96px 0" }}>
|
||||||
|
<SyncOutlined spin />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meeting) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||||
|
<Empty description="会议不存在" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={meeting.title || "实时识别中"}
|
||||||
|
subtitle={`会议编号 #${meeting.id} · ${dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm")}`}
|
||||||
|
extra={<Badge color={statusColor} text={statusText} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||||
|
{!sessionDraft ? (
|
||||||
|
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="缺少实时识别启动配置"
|
||||||
|
description="这个会议的实时会中配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||||
|
action={<Button size="small" onClick={() => navigate("/meeting-live-create")}>返回创建页</Button>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Row gutter={16} style={{ height: "100%" }}>
|
||||||
|
<Col xs={24} xl={7} style={{ height: "100%" }}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||||
|
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
<div style={{ padding: 14, borderRadius: 16, background: "linear-gradient(135deg, #0f172a 0%, #1e40af 60%, #60a5fa 100%)", color: "#fff" }}>
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<Tag color="blue" style={{ width: "fit-content", margin: 0 }}>LIVE SESSION</Tag>
|
||||||
|
<Title level={4} style={{ color: "#fff", margin: 0 }}>会中实时识别</Title>
|
||||||
|
<Text style={{ color: "rgba(255,255,255,0.82)" }}>会中页只保留控制区和实时转写流。</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space style={{ width: "100%" }}>
|
||||||
|
<Button type="primary" icon={<PlayCircleOutlined />} disabled={recording || connecting || finishing} loading={connecting} onClick={() => void handleStart()} style={{ flex: 1, height: 42 }}>
|
||||||
|
开始识别
|
||||||
|
</Button>
|
||||||
|
<Button danger icon={<PauseCircleOutlined />} disabled={(!recording && !connecting) || finishing} loading={finishing} onClick={() => void handleStop(true)} style={{ flex: 1, height: 42 }}>
|
||||||
|
结束会议
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={12}><Statistic title="已识别片段" value={finalTranscriptCount} /></Col>
|
||||||
|
<Col span={12}><Statistic title="实时字数" value={totalTranscriptChars} /></Col>
|
||||||
|
<Col span={12}><Statistic title="已录时长" value={formatClock(elapsedSeconds)} prefix={<ClockCircleOutlined />} /></Col>
|
||||||
|
<Col span={12}><Statistic title="说话人区分" value={sessionDraft.useSpkId ? "开启" : "关闭"} /></Col>
|
||||||
|
</Row>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 12, padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||||
|
<Space direction="vertical" size={10} style={{ width: "100%" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">ASR 模型</Text><Text strong>{sessionDraft.asrModelName}</Text></div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">总结模型</Text><Text strong>{sessionDraft.summaryModelName}</Text></div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">识别模式</Text><Text strong>{sessionDraft.mode}</Text></div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">热词数量</Text><Text strong>{sessionDraft.hotwords.length}</Text></div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">麦克风输入</Text>
|
||||||
|
<div style={{ marginTop: 8, height: 10, borderRadius: 999, background: "#e2e8f0", overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${audioLevel}%`, height: "100%", background: "linear-gradient(90deg, #38bdf8, #2563eb)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "auto" }}>
|
||||||
|
<Alert type="info" showIcon message="异常关闭保护" description="最终转录会实时写入会议;页面关闭时会自动尝试结束会议并触发总结,避免会中内容整体丢失。" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} xl={17} style={{ height: "100%" }}>
|
||||||
|
<Card bordered={false} style={{ borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)", height: "100%" }} bodyStyle={{ padding: 0, height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "16px 20px", borderBottom: "1px solid #f0f0f0", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>实时转写流</Title>
|
||||||
|
<Text type="secondary">优先展示最终片段,流式草稿保留在底部作为当前正在识别的内容。</Text>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag icon={<SoundOutlined />} color={recording ? "processing" : "default"}>{recording ? "采集中" : connecting ? "连接中" : "待命"}</Tag>
|
||||||
|
<Tag color="blue">{sessionDraft.asrModelName}</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={transcriptRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 18, background: "linear-gradient(180deg, #f8fafc 0%, #ffffff 65%, #f8fafc 100%)" }}>
|
||||||
|
{transcripts.length === 0 && !streamingText ? (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<Empty description="会议已创建,点击左侧开始识别即可进入转写" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||||
|
{transcripts.map((item) => (
|
||||||
|
<div key={item.id} style={{ padding: 16, borderRadius: 16, background: "#fff", boxShadow: "0 6px 18px rgba(15,23,42,0.05)", display: "grid", gridTemplateColumns: "46px 1fr", gap: 14 }}>
|
||||||
|
<div style={{ width: 46, height: 46, borderRadius: "50%", background: "#e6f4ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{item.speakerName.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space wrap size={[8, 8]} style={{ marginBottom: 6 }}>
|
||||||
|
<Text strong>{item.speakerName}</Text>
|
||||||
|
{item.userId ? <Tag color="blue">UID: {item.userId}</Tag> : null}
|
||||||
|
<Tag icon={<ClockCircleOutlined />}>{formatTranscriptTime(item.startTime)} - {formatTranscriptTime(item.endTime)}</Tag>
|
||||||
|
</Space>
|
||||||
|
<div style={{ color: "#1f2937", lineHeight: 1.8 }}>{item.text}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{streamingText ? (
|
||||||
|
<div style={{ padding: 16, borderRadius: 16, background: "linear-gradient(135deg, rgba(230,244,255,0.9), rgba(245,250,255,0.96))", border: "1px solid #b7d8ff", display: "grid", gridTemplateColumns: "46px 1fr", gap: 14 }}>
|
||||||
|
<div style={{ width: 46, height: 46, borderRadius: "50%", background: "#1677ff", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700 }}>
|
||||||
|
{streamingSpeaker.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space wrap size={[8, 8]} style={{ marginBottom: 6 }}>
|
||||||
|
<Text strong>{streamingSpeaker}</Text>
|
||||||
|
<Tag color="processing">流式草稿</Tag>
|
||||||
|
</Space>
|
||||||
|
<div style={{ color: "#334155", lineHeight: 1.8 }}>{streamingText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -155,7 +155,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', backgroundColor: '#f8f9fa' }}>
|
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
|
||||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||||
<Title level={3}>声纹采集中心</Title>
|
<Title level={3}>声纹采集中心</Title>
|
||||||
<Text type="secondary">注册唯一的声纹特征,让 AI 在会议中精准识别出您的每一句发言。</Text>
|
<Text type="secondary">注册唯一的声纹特征,让 AI 在会议中精准识别出您的每一句发言。</Text>
|
||||||
|
|
@ -163,18 +163,18 @@ const SpeakerReg: React.FC = () => {
|
||||||
<Row gutter={24} style={{ marginTop: 24 }}>
|
<Row gutter={24} style={{ marginTop: 24 }}>
|
||||||
{/* 左侧:采集与录音 */}
|
{/* 左侧:采集与录音 */}
|
||||||
<Col span={15}>
|
<Col span={15}>
|
||||||
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
<Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||||
<Tabs defaultActiveKey="record">
|
<Tabs defaultActiveKey="record">
|
||||||
<Tabs.TabPane tab="在线录制" key="record">
|
<Tabs.TabPane tab="在线录制" key="record">
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
backgroundColor: '#fafafa',
|
background: 'var(--app-bg-surface-soft)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: '1px solid #f0f0f0',
|
border: '1px solid var(--app-border-color)',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
<Paragraph style={{ fontSize: 18, color: recording ? '#1890ff' : '#262626', fontWeight: 500, lineHeight: 1.8 }}>
|
<Paragraph style={{ fontSize: 18, color: recording ? 'var(--app-primary-color)' : 'var(--app-text-main)', fontWeight: 500, lineHeight: 1.8 }}>
|
||||||
“{REG_CONTENT}”
|
“{REG_CONTENT}”
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Text type="secondary" size="small">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
<Text type="secondary" size="small">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
||||||
|
|
@ -221,7 +221,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane tab="本地上传" key="upload">
|
<Tabs.TabPane tab="本地上传" key="upload">
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0', border: '1px dashed #d9d9d9', borderRadius: '8px', marginBottom: 24, backgroundColor: '#fafafa' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0', border: '1px dashed var(--app-border-color)', borderRadius: '8px', marginBottom: 24, background: 'var(--app-bg-surface-soft)' }}>
|
||||||
<Upload {...uploadProps} accept="audio/*">
|
<Upload {...uploadProps} accept="audio/*">
|
||||||
<Button icon={<UploadOutlined />} size="large">选择音频文件</Button>
|
<Button icon={<UploadOutlined />} size="large">选择音频文件</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
|
|
@ -233,7 +233,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
<div style={{ backgroundColor: '#f0f5ff', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid #adc6ff' }}>
|
<div style={{ background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid color-mix(in srgb, var(--app-primary-color) 32%, var(--app-border-color))' }}>
|
||||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Text strong>采集完成,请试听:</Text>
|
<Text strong>采集完成,请试听:</Text>
|
||||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}>清除文件</Button>
|
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}>清除文件</Button>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
|
||||||
const isError = percent < 0;
|
const isError = percent < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}>
|
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
|
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
|
||||||
|
|
@ -137,12 +137,12 @@ export const Dashboard: React.FC = () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
|
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
|
||||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
{statCards.map((s, idx) => (
|
{statCards.map((s, idx) => (
|
||||||
<Col span={6} key={idx}>
|
<Col span={6} key={idx}>
|
||||||
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
<Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||||
value={s.value || 0}
|
value={s.value || 0}
|
||||||
|
|
@ -162,7 +162,7 @@ export const Dashboard: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}
|
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
loading={dashboardLoading}
|
loading={dashboardLoading}
|
||||||
|
|
@ -182,7 +182,7 @@ export const Dashboard: React.FC = () => {
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
{item.tags?.split(',').filter(Boolean).map((t) => (
|
{item.tags?.split(',').filter(Boolean).map((t) => (
|
||||||
<Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AudioOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
CustomerServiceOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
VideoCameraAddOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Empty, Skeleton, Tag, Typography } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { getRecentTasks } from "@/api/business/dashboard";
|
||||||
|
import type { MeetingVO } from "@/api/business/meeting";
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
type QuickEntry = {
|
||||||
|
title: string;
|
||||||
|
badge: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string[];
|
||||||
|
accent: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecentCard = {
|
||||||
|
id: number | string;
|
||||||
|
title: string;
|
||||||
|
duration: string;
|
||||||
|
time: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackRecentCards: RecentCard[] = [
|
||||||
|
{
|
||||||
|
id: "sample-1",
|
||||||
|
title: "2026-03-25 16:05 记录",
|
||||||
|
duration: "01:10",
|
||||||
|
time: "今天 16:05",
|
||||||
|
tags: ["发言人", "降噪", "速度", "模仿", "暂停"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sample-2",
|
||||||
|
title: "【示例】开会用通义听悟,高效又省心",
|
||||||
|
duration: "02:14",
|
||||||
|
time: "2026-03-24 11:04",
|
||||||
|
tags: ["会议日程", "笔记", "发言人", "协同", "纪要"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sample-3",
|
||||||
|
title: "【示例】上课用通义听悟,学习效率 UPUP",
|
||||||
|
duration: "02:01",
|
||||||
|
time: "2026-03-23 11:04",
|
||||||
|
tags: ["转写", "笔记", "学习", "教学音频", "课程音频"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildRecentCards(tasks: MeetingVO[]): RecentCard[] {
|
||||||
|
if (!tasks.length) {
|
||||||
|
return fallbackRecentCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks.slice(0, 3).map((task, index) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
duration: `0${index + 1}:${10 + index * 12}`,
|
||||||
|
time: dayjs(task.meetingTime || task.createdAt).format("YYYY-MM-DD HH:mm"),
|
||||||
|
tags: task.tags?.split(",").filter(Boolean).slice(0, 5) || ["转写", "总结", "纪要"]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRecentTasks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getRecentTasks();
|
||||||
|
setRecentTasks(response.data.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Home recent tasks load failed", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchRecentTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const quickEntries = useMemo<QuickEntry[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: "开启实时会议",
|
||||||
|
badge: "实时协作",
|
||||||
|
icon: <AudioOutlined />,
|
||||||
|
description: ["边开会边转写,自动沉淀结构化纪要", "适合讨论会、评审会、客户沟通"],
|
||||||
|
accent: "violet",
|
||||||
|
onClick: () => navigate("/meeting-live-create")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "上传音频",
|
||||||
|
badge: "离线整理",
|
||||||
|
icon: <VideoCameraAddOutlined />,
|
||||||
|
description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"],
|
||||||
|
accent: "cyan",
|
||||||
|
onClick: () => navigate("/meeting-create")
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentCards = useMemo(() => buildRecentCards(recentTasks), [recentTasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-landing">
|
||||||
|
<div className="home-landing__halo home-landing__halo--large" />
|
||||||
|
<div className="home-landing__halo home-landing__halo--small" />
|
||||||
|
|
||||||
|
<section className="home-landing__hero">
|
||||||
|
<div className="home-landing__copy">
|
||||||
|
<div className="home-landing__eyebrow">
|
||||||
|
<RadarChartOutlined />
|
||||||
|
<span>iMeeting 智能首页</span>
|
||||||
|
</div>
|
||||||
|
<Title level={1} className="home-landing__title">
|
||||||
|
每一次交流
|
||||||
|
<span> 都有迹可循</span>
|
||||||
|
</Title>
|
||||||
|
<div className="home-landing__status">
|
||||||
|
<div className="home-landing__status-item">
|
||||||
|
<CustomerServiceOutlined />
|
||||||
|
<span>支持实时记录、上传转写、纪要整理</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-landing__status-item">
|
||||||
|
<SoundOutlined />
|
||||||
|
<span>两种入口,覆盖实时会议和离线录音</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-landing__visual" aria-hidden="true">
|
||||||
|
<div className="home-landing__visual-frame">
|
||||||
|
<div className="home-landing__visual-glow home-landing__visual-glow--primary" />
|
||||||
|
<div className="home-landing__visual-glow home-landing__visual-glow--secondary" />
|
||||||
|
<div className="home-landing__visual-grid" />
|
||||||
|
<div className="home-landing__visual-beam" />
|
||||||
|
<div className="home-landing__visual-radar" />
|
||||||
|
<div className="home-landing__visual-pulse home-landing__visual-pulse--one" />
|
||||||
|
<div className="home-landing__visual-pulse home-landing__visual-pulse--two" />
|
||||||
|
<div className="home-landing__visual-chip home-landing__visual-chip--top">Live capture</div>
|
||||||
|
<div className="home-landing__visual-chip home-landing__visual-chip--bottom">Speaker focus</div>
|
||||||
|
<div className="home-landing__visual-waveform">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span key={`visual-wave-${index}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-landing__entry-stage">
|
||||||
|
<div className="home-landing__entry-grid home-landing__entry-grid--two">
|
||||||
|
{quickEntries.map((entry) => (
|
||||||
|
<article
|
||||||
|
key={entry.title}
|
||||||
|
className={`home-entry-card home-entry-card--${entry.accent}`}
|
||||||
|
onClick={entry.onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
entry.onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="home-entry-card__shine" aria-hidden="true" />
|
||||||
|
<div className="home-entry-card__topline">
|
||||||
|
<div className="home-entry-card__icon">{entry.icon}</div>
|
||||||
|
<div className="home-entry-card__badge">{entry.badge}</div>
|
||||||
|
</div>
|
||||||
|
<Title level={3}>{entry.title}</Title>
|
||||||
|
<div className="home-entry-card__content">
|
||||||
|
{entry.description.map((line) => (
|
||||||
|
<Text key={line} className="home-entry-card__line">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="home-entry-card__media" aria-hidden="true">
|
||||||
|
<div className="home-entry-card__track">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className="home-entry-card__pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="home-entry-card__cta" aria-hidden="true">
|
||||||
|
<span>点击直接进入</span>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-landing__soundstage" aria-hidden="true">
|
||||||
|
<div className="home-landing__board-glow" />
|
||||||
|
<div className="home-landing__board-grid" />
|
||||||
|
<div className="home-landing__board-panel home-landing__board-panel--summary">
|
||||||
|
<span className="home-landing__board-pill">Meeting Summary</span>
|
||||||
|
<div className="home-landing__board-lines">
|
||||||
|
<span className="home-landing__board-line home-landing__board-line--lg" />
|
||||||
|
<span className="home-landing__board-line home-landing__board-line--md" />
|
||||||
|
<span className="home-landing__board-line home-landing__board-line--sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="home-landing__board-panel home-landing__board-panel--activity">
|
||||||
|
<div className="home-landing__board-bars">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="home-landing__board-panel home-landing__board-panel--timeline">
|
||||||
|
<div className="home-landing__board-node home-landing__board-node--active" />
|
||||||
|
<div className="home-landing__board-node" />
|
||||||
|
<div className="home-landing__board-node" />
|
||||||
|
<div className="home-landing__board-rail" />
|
||||||
|
</div>
|
||||||
|
<div className="home-landing__board-stats">
|
||||||
|
<div className="home-landing__board-stat" />
|
||||||
|
<div className="home-landing__board-stat" />
|
||||||
|
<div className="home-landing__board-stat" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-landing__recent">
|
||||||
|
<div className="home-landing__section-head">
|
||||||
|
<Title level={3}>最近</Title>
|
||||||
|
<Button type="link" onClick={() => navigate("/meetings")}>
|
||||||
|
查看全部
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="home-landing__recent-grid">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="home-recent-card">
|
||||||
|
<Skeleton active paragraph={{ rows: 3 }} title={{ width: "70%" }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentCards.length ? (
|
||||||
|
<div className="home-landing__recent-grid">
|
||||||
|
{recentCards.map((card, index) => (
|
||||||
|
<article
|
||||||
|
key={card.id}
|
||||||
|
className="home-recent-card"
|
||||||
|
onClick={() => typeof card.id === "number" && navigate(`/meetings/${card.id}`)}
|
||||||
|
>
|
||||||
|
<div className="home-recent-card__pin" aria-hidden="true" />
|
||||||
|
<div className="home-recent-card__head">
|
||||||
|
<Title level={4}>{card.title}</Title>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="home-recent-card__tags">
|
||||||
|
{card.tags.map((tag) => (
|
||||||
|
<Tag key={`${card.id}-${tag}-${index}`}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="home-recent-card__foot">
|
||||||
|
<span>{card.duration}</span>
|
||||||
|
<span>{card.time}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="home-landing__empty">
|
||||||
|
<Empty description="暂无最近记录" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
import type { MenuRoute } from "@/types";
|
import type { MenuRoute } from "@/types";
|
||||||
|
|
||||||
|
const HomePage = lazy(() => import("@/pages/home"));
|
||||||
const Dashboard = lazy(() => import("@/pages/dashboard"));
|
const Dashboard = lazy(() => import("@/pages/dashboard"));
|
||||||
const Profile = lazy(() => import("@/pages/profile"));
|
const Profile = lazy(() => import("@/pages/profile"));
|
||||||
const Tenants = lazy(() => import("@/pages/organization/tenants"));
|
const Tenants = lazy(() => import("@/pages/organization/tenants"));
|
||||||
|
|
@ -40,7 +41,7 @@ function LazyPage({ children }: { children: JSX.Element }) {
|
||||||
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
|
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
|
||||||
}
|
}
|
||||||
export const menuRoutes: MenuRoute[] = [
|
export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/", label: "总览", element: <Dashboard />, perm: "menu:dashboard" },
|
{ path: "/", label: "首页", element: <HomePage />, perm: "menu:dashboard" },
|
||||||
{ path: "/profile", label: "个人中心", element: <Profile /> },
|
{ path: "/profile", label: "个人中心", element: <Profile /> },
|
||||||
{ path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" },
|
{ path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" },
|
||||||
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
||||||
|
|
@ -64,6 +65,7 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const extraRoutes = [
|
export const extraRoutes = [
|
||||||
|
{ path: "/dashboard-monitor", element: <Dashboard />, perm: "menu:dashboard" },
|
||||||
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
|
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
|
||||||
{ path: "/meeting-live-create", element: <RealtimeAsr />, perm: "menu:meeting" },
|
{ path: "/meeting-live-create", element: <RealtimeAsr />, perm: "menu:meeting" },
|
||||||
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" }
|
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" }
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,26 @@ const DEFAULT_COLOR = '#1677ff';
|
||||||
const DEFAULT_MODE: ThemeMode = 'default';
|
const DEFAULT_MODE: ThemeMode = 'default';
|
||||||
const DEFAULT_LAYOUT: LayoutMode = 'side';
|
const DEFAULT_LAYOUT: LayoutMode = 'side';
|
||||||
|
|
||||||
|
function hexToRgbChannels(color: string): string {
|
||||||
|
const normalized = color.trim().replace('#', '');
|
||||||
|
|
||||||
|
if (/^[0-9a-fA-F]{3}$/.test(normalized)) {
|
||||||
|
const [r, g, b] = normalized.split('');
|
||||||
|
return `${parseInt(r + r, 16)}, ${parseInt(g + g, 16)}, ${parseInt(b + b, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[0-9a-fA-F]{6}$/.test(normalized)) {
|
||||||
|
return `${parseInt(normalized.slice(0, 2), 16)}, ${parseInt(normalized.slice(2, 4), 16)}, ${parseInt(normalized.slice(4, 6), 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '22, 119, 255';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeColor(color: string) {
|
||||||
|
document.documentElement.style.setProperty('--app-primary-color', color);
|
||||||
|
document.documentElement.style.setProperty('--app-primary-rgb', hexToRgbChannels(color));
|
||||||
|
}
|
||||||
|
|
||||||
const getColorStorageKey = () => {
|
const getColorStorageKey = () => {
|
||||||
const username = localStorage.getItem("username") || "default";
|
const username = localStorage.getItem("username") || "default";
|
||||||
return `unis_theme_color_${username}`;
|
return `unis_theme_color_${username}`;
|
||||||
|
|
@ -40,7 +60,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
set({ colorPrimary: color });
|
set({ colorPrimary: color });
|
||||||
const key = getColorStorageKey();
|
const key = getColorStorageKey();
|
||||||
localStorage.setItem(key, color);
|
localStorage.setItem(key, color);
|
||||||
document.documentElement.style.setProperty('--app-primary-color', color);
|
applyThemeColor(color);
|
||||||
},
|
},
|
||||||
setThemeMode: (mode: ThemeMode) => {
|
setThemeMode: (mode: ThemeMode) => {
|
||||||
set({ themeMode: mode });
|
set({ themeMode: mode });
|
||||||
|
|
@ -68,7 +88,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
const layout = storedLayout || DEFAULT_LAYOUT;
|
const layout = storedLayout || DEFAULT_LAYOUT;
|
||||||
|
|
||||||
set({ colorPrimary: color, themeMode: mode, layoutMode: layout });
|
set({ colorPrimary: color, themeMode: mode, layoutMode: layout });
|
||||||
document.documentElement.style.setProperty('--app-primary-color', color);
|
applyThemeColor(color);
|
||||||
document.documentElement.setAttribute('data-theme', mode);
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue