feat: 添加实时会议和主页组件
- 新增 `RealtimeAsr` 组件,用于创建和配置实时会议 - 新增 `HomePage` 组件,展示最近的会议记录和快速入口 - 新增 `RealtimeAsrSession` 组件,用于实时会议的会中识别和转录dev_na
parent
2b1d7b8a2e
commit
653a9f7ef4
|
|
@ -129,6 +129,10 @@
|
|||
<artifactId>unisbase-spring-boot-starter</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
package com.imeeting.controller.biz;
|
||||
|
||||
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.MeetingQueryService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
|
@ -19,10 +16,10 @@ import java.util.Map;
|
|||
@RequestMapping("/api/biz/dashboard")
|
||||
public class DashboardController {
|
||||
|
||||
private final MeetingService meetingService;
|
||||
private final MeetingQueryService meetingQueryService;
|
||||
|
||||
public DashboardController(MeetingService meetingService) {
|
||||
this.meetingService = meetingService;
|
||||
public DashboardController(MeetingQueryService meetingQueryService) {
|
||||
this.meetingQueryService = meetingQueryService;
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
|
|
@ -30,7 +27,7 @@ public class DashboardController {
|
|||
public ApiResponse<Map<String, Object>> getStats() {
|
||||
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
|
||||
return ApiResponse.ok(meetingService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin));
|
||||
return ApiResponse.ok(meetingQueryService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin));
|
||||
}
|
||||
|
||||
@GetMapping("/recent")
|
||||
|
|
@ -38,6 +35,6 @@ public class DashboardController {
|
|||
public ApiResponse<List<MeetingVO>> getRecent() {
|
||||
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
|
||||
return ApiResponse.ok(meetingService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10));
|
||||
return ApiResponse.ok(meetingQueryService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class MeetingDTO {
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
public class CreateMeetingCommand {
|
||||
private String title;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
|
@ -17,8 +16,6 @@ public class MeetingDTO {
|
|||
private String participants;
|
||||
private String tags;
|
||||
private String audioUrl;
|
||||
private Long creatorId;
|
||||
private String creatorName;
|
||||
private Long asrModelId;
|
||||
private Long summaryModelId;
|
||||
private Long promptId;
|
||||
|
|
@ -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.imeeting.dto.biz.MeetingDTO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.unisbase.dto.PageResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MeetingService extends IService<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;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.imeeting.dto.biz.MeetingDTO;
|
||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.HotWord;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.entity.biz.PromptTemplate;
|
||||
import com.imeeting.event.MeetingCreatedEvent;
|
||||
import com.imeeting.mapper.biz.AiTaskMapper;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MeetingServiceImpl extends ServiceImpl<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:
|
||||
port: 8080
|
||||
port: ${SERVER_PORT:8082}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.51.199:5432/imeeting_db}
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_test}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:postgres}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
|
||||
data:
|
||||
redis:
|
||||
host: ${SPRING_DATA_REDIS_HOST:10.100.51.199}
|
||||
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
|
||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
|
||||
database: ${SPRING_DATA_REDIS_DATABASE:15}
|
||||
cache:
|
||||
type: redis
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 2048MB
|
||||
max-request-size: 2048MB
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
time-zone: GMT+8
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:}
|
||||
database: ${SPRING_DATA_REDIS_DATABASE:16}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: isDeleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
unisbase:
|
||||
web:
|
||||
auth-endpoints-enabled: true
|
||||
management-endpoints-enabled: true
|
||||
tenant:
|
||||
ignoreTables:
|
||||
- biz_ai_tasks
|
||||
- biz_meeting_transcripts
|
||||
- biz_speakers
|
||||
security:
|
||||
enabled: true
|
||||
mode: embedded
|
||||
jwt-secret: ${SECURITY_JWT_SECRET:change-me-please-change-me-32bytes}
|
||||
auth-header: Authorization
|
||||
token-prefix: "Bearer "
|
||||
permit-all-urls:
|
||||
- /actuator/health
|
||||
- /api/static/**
|
||||
jwt-secret: ${SECURITY_JWT_SECRET:change-me-test-jwt-secret-32bytes}
|
||||
internal-auth:
|
||||
enabled: true
|
||||
secret: change-me-internal-secret
|
||||
header-name: X-Internal-Secret
|
||||
secret: ${INTERNAL_AUTH_SECRET:change-me-test-internal-secret}
|
||||
app:
|
||||
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:8080} # 本地应用对外暴露的 IP 和端口
|
||||
upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/}
|
||||
resource-prefix: /api/static/
|
||||
captcha:
|
||||
ttl-seconds: 120
|
||||
max-attempts: 5
|
||||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
|
||||
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/}
|
||||
|
|
@ -1,24 +1,15 @@
|
|||
server:
|
||||
port: 8081
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://10.100.51.199:5432/imeeting_db
|
||||
username: postgres
|
||||
password: postgres
|
||||
data:
|
||||
redis:
|
||||
host: 10.100.51.199
|
||||
port: 6379
|
||||
password: unis@123
|
||||
database: 15
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
cache:
|
||||
type: redis
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 2048MB
|
||||
max-request-size: 2048MB
|
||||
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
|
|
@ -28,7 +19,6 @@ spring:
|
|||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: isDeleted
|
||||
|
|
@ -47,7 +37,6 @@ unisbase:
|
|||
security:
|
||||
enabled: true
|
||||
mode: embedded
|
||||
jwt-secret: change-me-please-change-me-32bytes
|
||||
auth-header: Authorization
|
||||
token-prefix: "Bearer "
|
||||
permit-all-urls:
|
||||
|
|
@ -55,11 +44,8 @@ unisbase:
|
|||
- /api/static/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
secret: change-me-internal-secret
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
server-base-url: http://10.100.52.13:${server.port} # 本地应用对外暴露的 IP 和端口
|
||||
upload-path: D:/data/imeeting/uploads/
|
||||
resource-prefix: /api/static/
|
||||
captcha:
|
||||
ttl-seconds: 120
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
export interface MeetingDTO {
|
||||
export interface CreateMeetingCommand {
|
||||
id?: number;
|
||||
title: string;
|
||||
meetingTime: string;
|
||||
|
|
@ -31,6 +31,24 @@ export interface MeetingDTO {
|
|||
hotWords?: string[];
|
||||
}
|
||||
|
||||
export type MeetingDTO = CreateMeetingCommand;
|
||||
|
||||
export interface UpdateMeetingBasicCommand {
|
||||
meetingId: number;
|
||||
title?: string;
|
||||
meetingTime?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand;
|
||||
|
||||
export interface UpdateMeetingSummaryCommand {
|
||||
meetingId: number;
|
||||
summaryContent: string;
|
||||
}
|
||||
|
||||
export type MeetingUpdateSummaryDTO = UpdateMeetingSummaryCommand;
|
||||
|
||||
export const getMeetingPage = (params: {
|
||||
current: number;
|
||||
size: number;
|
||||
|
|
@ -43,7 +61,7 @@ export const getMeetingPage = (params: {
|
|||
);
|
||||
};
|
||||
|
||||
export const createMeeting = (data: MeetingDTO) => {
|
||||
export const createMeeting = (data: CreateMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting",
|
||||
data
|
||||
|
|
@ -58,7 +76,7 @@ export interface RealtimeTranscriptItemDTO {
|
|||
endTime?: number;
|
||||
}
|
||||
|
||||
export const createRealtimeMeeting = (data: MeetingDTO) => {
|
||||
export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting/realtime/start",
|
||||
data
|
||||
|
|
@ -97,40 +115,67 @@ export interface MeetingTranscriptVO {
|
|||
|
||||
export const getMeetingDetail = (id: number) => {
|
||||
return http.get<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
`/api/biz/meeting/detail/${id}`
|
||||
`/api/biz/meeting/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getTranscripts = (id: number) => {
|
||||
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 }>(
|
||||
"/api/biz/meeting/speaker",
|
||||
params
|
||||
);
|
||||
};
|
||||
|
||||
export const reSummary = (params: { meetingId: number; summaryModelId: number; promptId: number }) => {
|
||||
export interface MeetingResummaryDTO {
|
||||
meetingId: number;
|
||||
summaryModelId: number;
|
||||
promptId: number;
|
||||
}
|
||||
|
||||
export const reSummary = (params: MeetingResummaryDTO) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
"/api/biz/meeting/re-summary",
|
||||
`/api/biz/meeting/${params.meetingId}/summary/regenerate`,
|
||||
params
|
||||
);
|
||||
};
|
||||
|
||||
export const updateMeeting = (data: Partial<MeetingVO>) => {
|
||||
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
"/api/biz/meeting",
|
||||
`/api/biz/meeting/${data.meetingId}/basic`,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => {
|
||||
export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => {
|
||||
return http.put<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
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export function useAuth() {
|
|||
const logout = () => {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
localStorage.removeItem("displayName");
|
||||
sessionStorage.removeItem("userProfile");
|
||||
setAccessToken(null);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,39 +1,105 @@
|
|||
:root {
|
||||
:root {
|
||||
--app-primary-color: #1677ff;
|
||||
--app-bg-main: radial-gradient(circle at top, rgba(56, 154, 255, 0.08), transparent 26%), linear-gradient(180deg, #f3f7fb 0%, #eef3f8 100%);
|
||||
--app-bg-card: rgba(255, 255, 255, 0.92);
|
||||
--app-primary-rgb: 22, 119, 255;
|
||||
--app-bg-main:
|
||||
radial-gradient(circle at 12% 18%, rgba(136, 161, 255, 0.18), transparent 22%),
|
||||
radial-gradient(circle at 84% 14%, rgba(131, 217, 255, 0.2), transparent 24%),
|
||||
radial-gradient(circle at 68% 78%, rgba(255, 207, 228, 0.12), transparent 20%),
|
||||
linear-gradient(180deg, #fcfdff 0%, #f6f9ff 38%, #eff4fb 100%);
|
||||
--app-bg-overlay:
|
||||
linear-gradient(rgba(255, 255, 255, 0.34) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.34) 1px, transparent 1px);
|
||||
--app-bg-overlay-size: 36px 36px;
|
||||
--app-bg-card: rgba(255, 255, 255, 0.74);
|
||||
--app-text-main: #1f2937;
|
||||
--app-border-color: rgba(15, 93, 166, 0.06);
|
||||
--app-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
--app-border-color: rgba(103, 126, 189, 0.12);
|
||||
--app-shadow: 0 18px 40px rgba(100, 118, 171, 0.1);
|
||||
--app-bg-page: rgba(255, 255, 255, 0.18);
|
||||
--app-bg-surface-soft: rgba(255, 255, 255, 0.56);
|
||||
--app-bg-surface-strong: rgba(255, 255, 255, 0.82);
|
||||
--app-text-muted: #66758f;
|
||||
}
|
||||
|
||||
:root[data-theme="minimal"] {
|
||||
--app-bg-main: #f9fafb;
|
||||
--app-bg-card: #ffffff;
|
||||
--app-bg-main:
|
||||
radial-gradient(circle at 14% 18%, rgba(222, 229, 241, 0.42), transparent 20%),
|
||||
linear-gradient(180deg, #fcfcfd 0%, #f5f7fb 100%);
|
||||
--app-bg-overlay:
|
||||
linear-gradient(rgba(255, 255, 255, 0.24) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.24) 1px, transparent 1px);
|
||||
--app-bg-card: rgba(255, 255, 255, 0.82);
|
||||
--app-text-main: #111827;
|
||||
--app-border-color: #e5e7eb;
|
||||
--app-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
--app-border-color: rgba(148, 163, 184, 0.16);
|
||||
--app-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
--app-bg-page: rgba(255, 255, 255, 0.16);
|
||||
--app-bg-surface-soft: rgba(255, 255, 255, 0.62);
|
||||
--app-bg-surface-strong: rgba(255, 255, 255, 0.86);
|
||||
--app-text-muted: #5b6474;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] {
|
||||
--app-bg-main: radial-gradient(circle at 50% 0%, rgba(22, 119, 255, 0.15), transparent 40%), #0d1117;
|
||||
--app-bg-card: rgba(30, 41, 59, 0.7);
|
||||
--app-bg-main:
|
||||
radial-gradient(circle at 20% 20%, rgba(52, 144, 255, 0.2), transparent 18%),
|
||||
radial-gradient(circle at 80% 18%, rgba(47, 211, 255, 0.14), transparent 20%),
|
||||
linear-gradient(180deg, #08101c 0%, #0d1526 54%, #101b30 100%);
|
||||
--app-bg-overlay:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
--app-bg-card: rgba(13, 23, 39, 0.62);
|
||||
--app-text-main: #e2e8f0;
|
||||
--app-border-color: rgba(22, 119, 255, 0.2);
|
||||
--app-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
--app-border-color: rgba(88, 151, 255, 0.18);
|
||||
--app-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
|
||||
--app-bg-page: rgba(5, 12, 24, 0.22);
|
||||
--app-bg-surface-soft: rgba(10, 21, 37, 0.72);
|
||||
--app-bg-surface-strong: rgba(8, 17, 31, 0.88);
|
||||
--app-text-muted: rgba(190, 206, 229, 0.74);
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--app-bg-main);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
color: var(--app-text-main);
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body::before {
|
||||
z-index: -3;
|
||||
background:
|
||||
radial-gradient(circle at 18% 24%, rgba(145, 167, 255, 0.22) 0%, rgba(145, 167, 255, 0) 28%),
|
||||
radial-gradient(circle at 78% 18%, rgba(121, 221, 255, 0.2) 0%, rgba(121, 221, 255, 0) 24%),
|
||||
radial-gradient(circle at 62% 74%, rgba(255, 209, 227, 0.16) 0%, rgba(255, 209, 227, 0) 22%);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
body::after {
|
||||
inset: 18px;
|
||||
z-index: -2;
|
||||
border-radius: 28px;
|
||||
background-image: var(--app-bg-overlay);
|
||||
background-size: var(--app-bg-overlay-size);
|
||||
opacity: 0.46;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.42), transparent 84%);
|
||||
}
|
||||
|
||||
.ant-layout {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
|
@ -41,6 +107,7 @@ body {
|
|||
.ant-layout-sider {
|
||||
background: var(--app-bg-card) !important;
|
||||
border-right: 1px solid var(--app-border-color);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
|
|
@ -53,31 +120,66 @@ body {
|
|||
color: var(--app-text-main) !important;
|
||||
}
|
||||
|
||||
/* Sider animation refinement */
|
||||
.app-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
background: rgba(151, 163, 184, 0.8);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: rgba(241, 245, 249, 0.7);
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root::before,
|
||||
#root::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#root::before {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(120% 48px at 8% 72%, rgba(124, 142, 255, 0.22) 0%, rgba(124, 142, 255, 0.08) 32%, rgba(124, 142, 255, 0) 58%),
|
||||
radial-gradient(120% 42px at 56% 76%, rgba(96, 209, 255, 0.18) 0%, rgba(96, 209, 255, 0.08) 30%, rgba(96, 209, 255, 0) 56%),
|
||||
radial-gradient(120% 54px at 90% 70%, rgba(160, 151, 255, 0.18) 0%, rgba(160, 151, 255, 0.08) 34%, rgba(160, 151, 255, 0) 60%);
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#root::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 8%;
|
||||
height: 180px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.08) 100%),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 147, 255, 0.04) 0,
|
||||
rgba(128, 147, 255, 0.04) 6px,
|
||||
transparent 6px,
|
||||
transparent 22px
|
||||
);
|
||||
mask-image: radial-gradient(120% 90% at 50% 100%, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.38) 54%, transparent 82%);
|
||||
}
|
||||
|
||||
.app-page {
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
|
|
@ -98,7 +200,7 @@ body {
|
|||
border-radius: 16px !important;
|
||||
box-shadow: var(--app-shadow);
|
||||
background: var(--app-bg-card);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
|
|
@ -165,17 +267,488 @@ body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.5);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing {
|
||||
--home-primary-rgb: 103, 103, 244;
|
||||
--home-primary: #6767f4;
|
||||
--home-title-color: #272554;
|
||||
--home-body-color: #5d678c;
|
||||
--home-muted-color: #9198b2;
|
||||
--home-surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--home-surface: rgba(247, 246, 255, 0.84);
|
||||
--home-surface-soft: rgba(255, 255, 255, 0.74);
|
||||
--home-border-strong: rgba(214, 205, 255, 0.96);
|
||||
--home-border: rgba(233, 228, 255, 0.96);
|
||||
--home-shadow: 0 22px 48px rgba(141, 132, 223, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, rgba(170, 146, 255, 0.04), transparent 18%),
|
||||
radial-gradient(circle at 82% 16%, rgba(165, 214, 255, 0.05), transparent 24%),
|
||||
radial-gradient(circle at 62% 74%, rgba(255, 206, 232, 0.03), transparent 16%),
|
||||
linear-gradient(180deg, #ffffff 0%, #ffffff 46%, #fefeff 100%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__halo--large {
|
||||
background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.18) 0%, rgba(var(--home-primary-rgb), 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__halo--small {
|
||||
background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.16) 0%, rgba(var(--home-primary-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__eyebrow,
|
||||
:root[data-theme="default"] .home-landing__status-item,
|
||||
:root[data-theme="default"] .home-landing__visual-frame,
|
||||
:root[data-theme="default"] .home-landing__soundstage,
|
||||
:root[data-theme="default"] .home-landing__board-panel,
|
||||
:root[data-theme="default"] .home-landing__board-stat,
|
||||
:root[data-theme="default"] .home-recent-card {
|
||||
border-color: var(--home-border) !important;
|
||||
box-shadow: var(--home-shadow) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__eyebrow,
|
||||
:root[data-theme="default"] .home-landing__status-item,
|
||||
:root[data-theme="default"] .home-landing__visual-chip,
|
||||
:root[data-theme="default"] .home-landing__visual-frame,
|
||||
:root[data-theme="default"] .home-landing__soundstage,
|
||||
:root[data-theme="default"] .home-landing__board-panel,
|
||||
:root[data-theme="default"] .home-landing__board-stat,
|
||||
:root[data-theme="default"] .home-recent-card,
|
||||
:root[data-theme="default"] .home-landing__empty {
|
||||
background: linear-gradient(180deg, var(--home-surface-strong), var(--home-surface)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__eyebrow,
|
||||
:root[data-theme="default"] .home-landing__visual-chip,
|
||||
:root[data-theme="default"] .home-landing__board-pill,
|
||||
:root[data-theme="default"] .home-entry-card__cta,
|
||||
:root[data-theme="default"] .home-entry-card:hover .home-entry-card__cta {
|
||||
color: var(--home-primary) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__title,
|
||||
:root[data-theme="default"] .home-entry-card h3,
|
||||
:root[data-theme="default"] .home-landing__section-head h3,
|
||||
:root[data-theme="default"] .home-recent-card__head h4 {
|
||||
color: var(--home-title-color) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__title span {
|
||||
color: var(--home-primary) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__status-item,
|
||||
:root[data-theme="default"] .home-entry-card__line,
|
||||
:root[data-theme="default"] .home-recent-card__tags .ant-tag {
|
||||
color: var(--home-body-color) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card__foot,
|
||||
:root[data-theme="default"] .home-recent-card__head .anticon {
|
||||
color: var(--home-muted-color) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card,
|
||||
:root[data-theme="default"] .home-entry-card--violet,
|
||||
:root[data-theme="default"] .home-entry-card--cyan {
|
||||
border-color: var(--home-border) !important;
|
||||
box-shadow: 0 18px 40px rgba(var(--home-primary-rgb), 0.14) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card--violet {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(252, 248, 255, 0.98) 0%, rgba(240, 234, 255, 0.92) 100%),
|
||||
linear-gradient(135deg, rgba(212, 189, 255, 0.28), rgba(214, 228, 255, 0.12)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card--cyan {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(244, 254, 255, 0.98) 0%, rgba(231, 249, 255, 0.92) 100%),
|
||||
linear-gradient(135deg, rgba(159, 233, 255, 0.28), rgba(202, 233, 255, 0.1)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card:focus-visible {
|
||||
outline-color: rgba(var(--home-primary-rgb), 0.34) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card:hover,
|
||||
:root[data-theme="default"] .home-recent-card:hover {
|
||||
border-color: var(--home-border-strong) !important;
|
||||
box-shadow: 0 24px 48px rgba(var(--home-primary-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card__icon,
|
||||
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__icon {
|
||||
background: linear-gradient(135deg, rgba(var(--home-primary-rgb), 0.96) 0%, rgba(var(--home-primary-rgb), 0.48) 100%) !important;
|
||||
box-shadow: 0 18px 34px rgba(var(--home-primary-rgb), 0.26) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card--violet .home-entry-card__icon {
|
||||
background: linear-gradient(135deg, #7569f2 0%, #9bb7ff 100%) !important;
|
||||
box-shadow: 0 18px 34px rgba(112, 103, 212, 0.24) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card__badge,
|
||||
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge,
|
||||
:root[data-theme="default"] .home-recent-card__tags .ant-tag {
|
||||
border-color: rgba(var(--home-primary-rgb), 0.18) !important;
|
||||
background: rgba(var(--home-primary-rgb), 0.1) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card--violet .home-entry-card__badge {
|
||||
border-color: rgba(193, 176, 255, 0.24) !important;
|
||||
background: rgba(193, 176, 255, 0.24) !important;
|
||||
color: #695fd2 !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card__badge {
|
||||
color: color-mix(in srgb, var(--home-primary) 72%, var(--home-title-color)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge {
|
||||
border-color: rgba(131, 220, 244, 0.22) !important;
|
||||
background: rgba(131, 220, 244, 0.22) !important;
|
||||
color: #3a9fc5 !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card__track span,
|
||||
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__track span,
|
||||
:root[data-theme="default"] .home-landing__visual-waveform span,
|
||||
:root[data-theme="default"] .home-landing__board-bars span,
|
||||
:root[data-theme="default"] .home-landing__board-line {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(var(--home-primary-rgb), 0.62)) !important;
|
||||
box-shadow: 0 8px 18px rgba(var(--home-primary-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-entry-card__pulse,
|
||||
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__pulse,
|
||||
:root[data-theme="default"] .home-landing__board-glow {
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(var(--home-primary-rgb), 0.34) 36%, rgba(var(--home-primary-rgb), 0.08) 72%, transparent 76%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__visual-grid,
|
||||
:root[data-theme="default"] .home-landing__board-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__visual-radar {
|
||||
background:
|
||||
radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.99) 0%, rgba(243, 244, 255, 0.94) 26%, rgba(var(--home-primary-rgb), 0.34) 48%, rgba(255, 255, 255, 0.04) 76%),
|
||||
linear-gradient(145deg, rgba(var(--home-primary-rgb), 0.28), rgba(242, 246, 255, 0.12)) !important;
|
||||
box-shadow:
|
||||
inset 0 0 54px rgba(255, 255, 255, 0.82),
|
||||
0 18px 38px rgba(var(--home-primary-rgb), 0.14) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__visual-radar::before,
|
||||
:root[data-theme="default"] .home-landing__visual-radar::after,
|
||||
:root[data-theme="default"] .home-landing__board-node,
|
||||
:root[data-theme="default"] .home-landing__board-node--active,
|
||||
:root[data-theme="default"] .home-landing__board-rail {
|
||||
border-color: rgba(var(--home-primary-rgb), 0.22) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-node {
|
||||
background: color-mix(in srgb, var(--home-surface-strong) 92%, #ffffff) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-node--active {
|
||||
border-color: rgba(var(--home-primary-rgb), 0.88) !important;
|
||||
box-shadow: 0 0 0 8px rgba(var(--home-primary-rgb), 0.2) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-rail {
|
||||
background: linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.16), rgba(var(--home-primary-rgb), 0.62), rgba(var(--home-primary-rgb), 0.24)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card__pin {
|
||||
background: var(--home-primary) !important;
|
||||
box-shadow: 0 0 0 6px rgba(var(--home-primary-rgb), 0.14) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__soundstage {
|
||||
border-color: rgba(180, 206, 255, 0.36) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(237, 245, 255, 0.96), rgba(205, 224, 255, 0.9)),
|
||||
linear-gradient(135deg, rgba(95, 138, 255, 0.3), rgba(121, 194, 255, 0.16) 56%, rgba(255, 255, 255, 0)) !important;
|
||||
box-shadow:
|
||||
0 24px 52px rgba(95, 138, 255, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__soundstage::before {
|
||||
border-color: rgba(182, 210, 255, 0.72) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-panel,
|
||||
:root[data-theme="default"] .home-landing__board-stat {
|
||||
border-color: rgba(160, 192, 255, 0.56) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(241, 248, 255, 0.96), rgba(215, 230, 255, 0.92)),
|
||||
linear-gradient(135deg, rgba(91, 129, 240, 0.2), rgba(125, 203, 255, 0.08)) !important;
|
||||
box-shadow:
|
||||
0 16px 30px rgba(89, 126, 226, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-pill {
|
||||
background: linear-gradient(90deg, rgba(111, 151, 255, 0.24), rgba(150, 219, 255, 0.2)) !important;
|
||||
color: #3b67d6 !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-line,
|
||||
:root[data-theme="default"] .home-landing__board-bars span,
|
||||
:root[data-theme="default"] .home-landing__board-rail {
|
||||
background: linear-gradient(90deg, rgba(74, 116, 226, 0.92), rgba(99, 161, 255, 0.62), rgba(150, 219, 255, 0.34)) !important;
|
||||
box-shadow: 0 10px 18px rgba(88, 117, 214, 0.14) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-glow {
|
||||
background: radial-gradient(circle, rgba(121, 175, 255, 0.52) 0%, rgba(118, 199, 255, 0.28) 42%, rgba(214, 226, 239, 0) 74%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-node {
|
||||
border-color: rgba(95, 138, 255, 0.44) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-landing__board-node--active {
|
||||
border-color: rgba(56, 97, 218, 0.92) !important;
|
||||
box-shadow: 0 0 0 8px rgba(110, 155, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing {
|
||||
--home-tech-rgb: var(--app-primary-rgb);
|
||||
--home-tech-primary: var(--app-primary-color);
|
||||
--home-tech-title: #eef4ff;
|
||||
--home-tech-body: rgba(214, 225, 243, 0.84);
|
||||
--home-tech-muted: rgba(167, 185, 214, 0.82);
|
||||
--home-tech-surface-strong: rgba(10, 18, 32, 0.9);
|
||||
--home-tech-surface: rgba(12, 22, 38, 0.76);
|
||||
--home-tech-surface-soft: rgba(14, 26, 44, 0.62);
|
||||
--home-tech-border: rgba(var(--home-tech-rgb), 0.22);
|
||||
--home-tech-border-strong: rgba(var(--home-tech-rgb), 0.34);
|
||||
--home-tech-shadow: 0 24px 58px rgba(0, 0, 0, 0.32);
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, rgba(var(--home-tech-rgb), 0.18), transparent 18%),
|
||||
radial-gradient(circle at 82% 16%, rgba(47, 211, 255, 0.14), transparent 24%),
|
||||
radial-gradient(circle at 62% 74%, rgba(var(--home-tech-rgb), 0.12), transparent 16%),
|
||||
linear-gradient(180deg, #08101c 0%, #0c1527 46%, #0f1b30 100%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__halo--large {
|
||||
background: radial-gradient(circle, rgba(var(--home-tech-rgb), 0.2) 0%, rgba(47, 211, 255, 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__halo--small {
|
||||
background: radial-gradient(circle, rgba(47, 211, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__eyebrow,
|
||||
:root[data-theme="tech"] .home-landing__status-item,
|
||||
:root[data-theme="tech"] .home-landing__visual-frame,
|
||||
:root[data-theme="tech"] .home-landing__soundstage,
|
||||
:root[data-theme="tech"] .home-landing__board-panel,
|
||||
:root[data-theme="tech"] .home-landing__board-stat,
|
||||
:root[data-theme="tech"] .home-recent-card {
|
||||
border-color: var(--home-tech-border) !important;
|
||||
box-shadow: var(--home-tech-shadow) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__eyebrow,
|
||||
:root[data-theme="tech"] .home-landing__status-item,
|
||||
:root[data-theme="tech"] .home-landing__visual-chip,
|
||||
:root[data-theme="tech"] .home-landing__visual-frame,
|
||||
:root[data-theme="tech"] .home-landing__soundstage,
|
||||
:root[data-theme="tech"] .home-landing__board-panel,
|
||||
:root[data-theme="tech"] .home-landing__board-stat,
|
||||
:root[data-theme="tech"] .home-recent-card,
|
||||
:root[data-theme="tech"] .home-landing__empty {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(11, 21, 36, 0.94), rgba(14, 26, 44, 0.78)),
|
||||
linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.12), rgba(47, 211, 255, 0.06)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__eyebrow,
|
||||
:root[data-theme="tech"] .home-landing__visual-chip,
|
||||
:root[data-theme="tech"] .home-landing__board-pill,
|
||||
:root[data-theme="tech"] .home-entry-card__cta,
|
||||
:root[data-theme="tech"] .home-entry-card:hover .home-entry-card__cta {
|
||||
color: var(--home-tech-primary) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__title,
|
||||
:root[data-theme="tech"] .home-entry-card h3,
|
||||
:root[data-theme="tech"] .home-landing__section-head h3,
|
||||
:root[data-theme="tech"] .home-recent-card__head h4 {
|
||||
color: var(--home-tech-title) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__title span {
|
||||
color: var(--home-tech-primary) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__status-item,
|
||||
:root[data-theme="tech"] .home-entry-card__line,
|
||||
:root[data-theme="tech"] .home-recent-card__tags .ant-tag {
|
||||
color: var(--home-tech-body) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-recent-card__foot,
|
||||
:root[data-theme="tech"] .home-recent-card__head .anticon {
|
||||
color: var(--home-tech-muted) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card,
|
||||
:root[data-theme="tech"] .home-entry-card--violet,
|
||||
:root[data-theme="tech"] .home-entry-card--cyan {
|
||||
border-color: var(--home-tech-border) !important;
|
||||
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card--violet {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(14, 22, 40, 0.96) 0%, rgba(22, 28, 52, 0.88) 100%),
|
||||
linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.26), rgba(120, 126, 255, 0.08)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card--cyan {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 24, 38, 0.96) 0%, rgba(14, 34, 46, 0.88) 100%),
|
||||
linear-gradient(135deg, rgba(47, 211, 255, 0.22), rgba(var(--home-tech-rgb), 0.08)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card:focus-visible {
|
||||
outline-color: rgba(var(--home-tech-rgb), 0.42) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card:hover,
|
||||
:root[data-theme="tech"] .home-recent-card:hover {
|
||||
border-color: var(--home-tech-border-strong) !important;
|
||||
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.34) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card__icon {
|
||||
background: linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.96) 0%, rgba(136, 178, 255, 0.62) 100%) !important;
|
||||
box-shadow: 0 18px 34px rgba(var(--home-tech-rgb), 0.28) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__icon {
|
||||
background: linear-gradient(135deg, #1fb6d9 0%, #72dfff 100%) !important;
|
||||
box-shadow: 0 18px 34px rgba(47, 211, 255, 0.24) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card__badge,
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge,
|
||||
:root[data-theme="tech"] .home-recent-card__tags .ant-tag {
|
||||
border-color: rgba(var(--home-tech-rgb), 0.2) !important;
|
||||
background: rgba(var(--home-tech-rgb), 0.12) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card__badge {
|
||||
color: rgba(188, 202, 255, 0.92) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge {
|
||||
border-color: rgba(47, 211, 255, 0.2) !important;
|
||||
background: rgba(47, 211, 255, 0.12) !important;
|
||||
color: rgba(151, 236, 255, 0.92) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card__track span,
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span,
|
||||
:root[data-theme="tech"] .home-landing__visual-waveform span,
|
||||
:root[data-theme="tech"] .home-landing__board-bars span,
|
||||
:root[data-theme="tech"] .home-landing__board-line {
|
||||
background: linear-gradient(180deg, rgba(233, 241, 255, 0.96), rgba(var(--home-tech-rgb), 0.5)) !important;
|
||||
box-shadow: 0 8px 18px rgba(var(--home-tech-rgb), 0.2) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span,
|
||||
:root[data-theme="tech"] .home-landing__board-bars span {
|
||||
background: linear-gradient(180deg, rgba(233, 247, 255, 0.92), rgba(47, 211, 255, 0.54)) !important;
|
||||
box-shadow: 0 8px 18px rgba(47, 211, 255, 0.22) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-entry-card__pulse,
|
||||
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__pulse,
|
||||
:root[data-theme="tech"] .home-landing__board-glow {
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.26) 36%, rgba(var(--home-tech-rgb), 0.08) 72%, transparent 76%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__visual-grid,
|
||||
:root[data-theme="tech"] .home-landing__board-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__visual-radar {
|
||||
background:
|
||||
radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.18) 0%, rgba(88, 133, 230, 0.26) 26%, rgba(var(--home-tech-rgb), 0.28) 48%, rgba(255, 255, 255, 0.02) 76%),
|
||||
linear-gradient(145deg, rgba(var(--home-tech-rgb), 0.22), rgba(47, 211, 255, 0.08)) !important;
|
||||
box-shadow:
|
||||
inset 0 0 54px rgba(255, 255, 255, 0.06),
|
||||
0 18px 38px rgba(0, 0, 0, 0.24) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__visual-radar::before,
|
||||
:root[data-theme="tech"] .home-landing__visual-radar::after,
|
||||
:root[data-theme="tech"] .home-landing__board-node,
|
||||
:root[data-theme="tech"] .home-landing__board-node--active,
|
||||
:root[data-theme="tech"] .home-landing__board-rail {
|
||||
border-color: rgba(var(--home-tech-rgb), 0.22) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__board-node {
|
||||
background: rgba(8, 17, 31, 0.96) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__board-node--active {
|
||||
border-color: rgba(var(--home-tech-rgb), 0.9) !important;
|
||||
box-shadow: 0 0 0 8px rgba(var(--home-tech-rgb), 0.16) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-landing__board-rail {
|
||||
background: linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.16), rgba(var(--home-tech-rgb), 0.62), rgba(47, 211, 255, 0.24)) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="tech"] .home-recent-card__pin {
|
||||
background: var(--home-tech-primary) !important;
|
||||
box-shadow: 0 0 0 6px rgba(var(--home-tech-rgb), 0.14) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body::after {
|
||||
inset: 10px;
|
||||
border-radius: 18px;
|
||||
opacity: 0.26;
|
||||
}
|
||||
|
||||
#root::before {
|
||||
bottom: 4%;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
#root::after {
|
||||
height: 120px;
|
||||
bottom: 5%;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as AntIcons from "@ant-design/icons";
|
||||
import * as AntIcons from "@ant-design/icons";
|
||||
import {
|
||||
BellOutlined,
|
||||
ApartmentOutlined,
|
||||
|
|
@ -74,6 +74,20 @@ export default function AppLayout() {
|
|||
const { load: loadPermissions, can } = usePermission();
|
||||
const { layoutMode } = useThemeStore();
|
||||
|
||||
const currentUserDisplayName = useMemo(() => {
|
||||
try {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
if (profileStr) {
|
||||
const profile = JSON.parse(profileStr) as { displayName?: string; username?: string };
|
||||
return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid cached profile and continue with storage fallback.
|
||||
}
|
||||
|
||||
return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
||||
}, [t]);
|
||||
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
const storedTenants = localStorage.getItem("availableTenants");
|
||||
|
|
@ -124,6 +138,8 @@ export default function AppLayout() {
|
|||
|
||||
const profile = await getCurrentUser();
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
localStorage.setItem("displayName", profile.displayName || profile.username || "");
|
||||
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
|
||||
|
||||
message.success(t("common.success"));
|
||||
window.location.reload();
|
||||
|
|
@ -283,7 +299,7 @@ export default function AppLayout() {
|
|||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
|
||||
<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>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
|
@ -301,22 +317,33 @@ export default function AppLayout() {
|
|||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
|
||||
{(!collapsed || isTop) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 700,
|
||||
color: "var(--app-primary-color)",
|
||||
letterSpacing: "0.5px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{platformConfig?.projectName || "UnisBase"}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
textDecoration: "none",
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
|
||||
{(!collapsed || isTop) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 700,
|
||||
color: "var(--app-primary-color)",
|
||||
letterSpacing: "0.5px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{platformConfig?.projectName || "UnisBase"}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -442,3 +469,5 @@ export default function AppLayout() {
|
|||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -86,8 +86,11 @@ export default function Login() {
|
|||
try {
|
||||
const profile = await getCurrentUser();
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
|
||||
localStorage.setItem("username", profile.username || values.username);
|
||||
} catch {
|
||||
sessionStorage.removeItem("userProfile");
|
||||
localStorage.removeItem("displayName");
|
||||
}
|
||||
|
||||
message.success(t("common.success"));
|
||||
|
|
|
|||
|
|
@ -352,13 +352,13 @@ const HotWords: React.FC = () => {
|
|||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="pinyin"
|
||||
label="拼音"
|
||||
tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"
|
||||
>
|
||||
<Input placeholder="例如:hui yi" />
|
||||
</Form.Item>
|
||||
{/*<Form.Item*/}
|
||||
{/* name="pinyin"*/}
|
||||
{/* label="拼音"*/}
|
||||
{/* tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"*/}
|
||||
{/*>*/}
|
||||
{/* <Input placeholder="例如:hui yi" />*/}
|
||||
{/*</Form.Item>*/}
|
||||
|
||||
<Row gutter={16}>
|
||||
<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 {
|
||||
Card,
|
||||
|
|
@ -41,7 +41,8 @@ import {
|
|||
getTranscripts,
|
||||
updateSpeakerInfo,
|
||||
reSummary,
|
||||
updateMeeting,
|
||||
updateMeetingBasic,
|
||||
updateMeetingSummary,
|
||||
MeetingVO,
|
||||
MeetingTranscriptVO,
|
||||
getMeetingProgress,
|
||||
|
|
@ -306,9 +307,9 @@ const MeetingDetail: React.FC = () => {
|
|||
const vals = await form.validateFields();
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await updateMeeting({
|
||||
await updateMeetingBasic({
|
||||
...vals,
|
||||
id: meeting?.id,
|
||||
meetingId: meeting?.id,
|
||||
tags: vals.tags?.join(','),
|
||||
});
|
||||
message.success('会议信息已更新');
|
||||
|
|
@ -324,8 +325,8 @@ const MeetingDetail: React.FC = () => {
|
|||
const handleSaveSummary = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await updateMeeting({
|
||||
id: meeting?.id,
|
||||
await updateMeetingSummary({
|
||||
meetingId: meeting?.id,
|
||||
summaryContent: summaryDraft,
|
||||
});
|
||||
message.success('总结内容已更新');
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const MeetingCreateForm: React.FC<{
|
|||
size="small"
|
||||
title={<Space><AudioOutlined /> 录音上传</Space>}
|
||||
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' }}
|
||||
>
|
||||
<Dragger
|
||||
|
|
@ -228,7 +228,7 @@ const MeetingCreateForm: React.FC<{
|
|||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
|
|
@ -263,7 +263,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
|
||||
return (
|
||||
<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 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()}>
|
||||
|
|
@ -327,7 +327,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
</div>
|
||||
<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 => (
|
||||
<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>
|
||||
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
|
||||
</div>
|
||||
|
|
@ -441,9 +441,9 @@ const Meetings: React.FC = () => {
|
|||
};
|
||||
|
||||
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' }}>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -487,7 +487,7 @@ const Meetings: React.FC = () => {
|
|||
onClose={() => setCreateDrawerVisible(false)}
|
||||
open={createDrawerVisible}
|
||||
destroyOnClose
|
||||
styles={{ body: { backgroundColor: '#f4f7f9', padding: '24px 32px' } }}
|
||||
styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right', padding: '12px 24px' }}>
|
||||
<Space size={12}>
|
||||
|
|
@ -538,10 +538,10 @@ const Meetings: React.FC = () => {
|
|||
</Modal>
|
||||
|
||||
<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; }
|
||||
@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.edit:hover { color: #1890ff; background: #e6f7ff; }
|
||||
.icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; }
|
||||
|
|
|
|||
|
|
@ -221,14 +221,14 @@ const PromptTemplates: React.FC = () => {
|
|||
key={item.id}
|
||||
hoverable
|
||||
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' }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
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'
|
||||
}}>
|
||||
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
|
||||
|
|
@ -255,7 +255,7 @@ const PromptTemplates: React.FC = () => {
|
|||
{item.tags?.map(tag => {
|
||||
const dictItem = dictTags.find(dt => dt.itemValue === tag);
|
||||
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}
|
||||
</Tag>
|
||||
);
|
||||
|
|
@ -286,7 +286,7 @@ const PromptTemplates: React.FC = () => {
|
|||
};
|
||||
|
||||
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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>提示词模板</Title>
|
||||
|
|
@ -295,7 +295,7 @@ const PromptTemplates: React.FC = () => {
|
|||
</Button>
|
||||
</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.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
|
|
@ -418,7 +418,7 @@ const PromptTemplates: React.FC = () => {
|
|||
/>
|
||||
</Form.Item>
|
||||
</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>
|
||||
</Col>
|
||||
</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 (
|
||||
<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' }}>
|
||||
<Title level={3}>声纹采集中心</Title>
|
||||
<Text type="secondary">注册唯一的声纹特征,让 AI 在会议中精准识别出您的每一句发言。</Text>
|
||||
|
|
@ -163,18 +163,18 @@ const SpeakerReg: React.FC = () => {
|
|||
<Row gutter={24} style={{ marginTop: 24 }}>
|
||||
{/* 左侧:采集与录音 */}
|
||||
<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.TabPane tab="在线录制" key="record">
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#fafafa',
|
||||
background: 'var(--app-bg-surface-soft)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
border: '1px solid var(--app-border-color)',
|
||||
marginBottom: 24,
|
||||
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}”
|
||||
</Paragraph>
|
||||
<Text type="secondary" size="small">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
||||
|
|
@ -221,7 +221,7 @@ const SpeakerReg: React.FC = () => {
|
|||
</div>
|
||||
</Tabs.TabPane>
|
||||
<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/*">
|
||||
<Button icon={<UploadOutlined />} size="large">选择音频文件</Button>
|
||||
</Upload>
|
||||
|
|
@ -233,7 +233,7 @@ const SpeakerReg: React.FC = () => {
|
|||
</Tabs>
|
||||
|
||||
{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' }}>
|
||||
<Text strong>采集完成,请试听:</Text>
|
||||
<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;
|
||||
|
||||
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 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
|
||||
|
|
@ -137,12 +137,12 @@ export const Dashboard: React.FC = () => {
|
|||
];
|
||||
|
||||
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' }}>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((s, idx) => (
|
||||
<Col span={6} key={idx}>
|
||||
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
||||
<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
|
||||
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||
value={s.value || 0}
|
||||
|
|
@ -162,7 +162,7 @@ export const Dashboard: React.FC = () => {
|
|||
</div>
|
||||
}
|
||||
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
|
||||
loading={dashboardLoading}
|
||||
|
|
@ -182,7 +182,7 @@ export const Dashboard: React.FC = () => {
|
|||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{item.tags?.split(',').filter(Boolean).map((t) => (
|
||||
<Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
||||
<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>
|
||||
</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 type { MenuRoute } from "@/types";
|
||||
|
||||
const HomePage = lazy(() => import("@/pages/home"));
|
||||
const Dashboard = lazy(() => import("@/pages/dashboard"));
|
||||
const Profile = lazy(() => import("@/pages/profile"));
|
||||
const Tenants = lazy(() => import("@/pages/organization/tenants"));
|
||||
|
|
@ -40,7 +41,7 @@ function LazyPage({ children }: { children: JSX.Element }) {
|
|||
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
|
||||
}
|
||||
export const menuRoutes: MenuRoute[] = [
|
||||
{ path: "/", label: "总览", element: <Dashboard />, perm: "menu:dashboard" },
|
||||
{ path: "/", label: "首页", element: <HomePage />, perm: "menu:dashboard" },
|
||||
{ path: "/profile", label: "个人中心", element: <Profile /> },
|
||||
{ path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" },
|
||||
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
||||
|
|
@ -64,6 +65,7 @@ export const menuRoutes: MenuRoute[] = [
|
|||
];
|
||||
|
||||
export const extraRoutes = [
|
||||
{ path: "/dashboard-monitor", element: <Dashboard />, perm: "menu:dashboard" },
|
||||
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
|
||||
{ path: "/meeting-live-create", element: <RealtimeAsr />, 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_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 username = localStorage.getItem("username") || "default";
|
||||
return `unis_theme_color_${username}`;
|
||||
|
|
@ -40,7 +60,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
|||
set({ colorPrimary: color });
|
||||
const key = getColorStorageKey();
|
||||
localStorage.setItem(key, color);
|
||||
document.documentElement.style.setProperty('--app-primary-color', color);
|
||||
applyThemeColor(color);
|
||||
},
|
||||
setThemeMode: (mode: ThemeMode) => {
|
||||
set({ themeMode: mode });
|
||||
|
|
@ -68,7 +88,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
|||
const layout = storedLayout || DEFAULT_LAYOUT;
|
||||
|
||||
set({ colorPrimary: color, themeMode: mode, layoutMode: layout });
|
||||
document.documentElement.style.setProperty('--app-primary-color', color);
|
||||
applyThemeColor(color);
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue