feat: 添加实时会议和主页组件

- 新增 `RealtimeAsr` 组件,用于创建和配置实时会议
- 新增 `HomePage` 组件,展示最近的会议记录和快速入口
- 新增 `RealtimeAsrSession` 组件,用于实时会议的会中识别和转录
dev_na
chenhao 2026-03-26 11:18:44 +08:00
parent 2b1d7b8a2e
commit 653a9f7ef4
48 changed files with 4936 additions and 1487 deletions

View File

@ -129,6 +129,10 @@
<artifactId>unisbase-spring-boot-starter</artifactId> <artifactId>unisbase-spring-boot-starter</artifactId>
<version>0.1.0</version> <version>0.1.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -1,9 +1,6 @@
package com.imeeting.controller.biz; package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@ -19,10 +16,10 @@ import java.util.Map;
@RequestMapping("/api/biz/dashboard") @RequestMapping("/api/biz/dashboard")
public class DashboardController { public class DashboardController {
private final MeetingService meetingService; private final MeetingQueryService meetingQueryService;
public DashboardController(MeetingService meetingService) { public DashboardController(MeetingQueryService meetingQueryService) {
this.meetingService = meetingService; this.meetingQueryService = meetingQueryService;
} }
@GetMapping("/stats") @GetMapping("/stats")
@ -30,7 +27,7 @@ public class DashboardController {
public ApiResponse<Map<String, Object>> getStats() { public ApiResponse<Map<String, Object>> getStats() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin()); boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin)); return ApiResponse.ok(meetingQueryService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin));
} }
@GetMapping("/recent") @GetMapping("/recent")
@ -38,6 +35,6 @@ public class DashboardController {
public ApiResponse<List<MeetingVO>> getRecent() { public ApiResponse<List<MeetingVO>> getRecent() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin()); boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10)); return ApiResponse.ok(meetingQueryService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10));
} }
} }

View File

@ -2,13 +2,12 @@ package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Data @Data
public class MeetingDTO { public class CreateMeetingCommand {
private Long id;
private Long tenantId;
private String title; private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@ -17,8 +16,6 @@ public class MeetingDTO {
private String participants; private String participants;
private String tags; private String tags;
private String audioUrl; private String audioUrl;
private Long creatorId;
private String creatorName;
private Long asrModelId; private Long asrModelId;
private Long summaryModelId; private Long summaryModelId;
private Long promptId; private Long promptId;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class RealtimeMeetingCompleteDTO {
private String audioUrl;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class UpdateMeetingParticipantsCommand {
private Long meetingId;
private String participants;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class UpdateMeetingSummaryCommand {
private Long meetingId;
private String summaryContent;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -2,28 +2,7 @@ package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.unisbase.dto.PageResult;
import java.util.List;
public interface MeetingService extends IService<Meeting> { public interface MeetingService extends IService<Meeting> {
MeetingVO createMeeting(MeetingDTO dto);
MeetingVO createRealtimeMeeting(MeetingDTO dto);
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin);
void deleteMeeting(Long id);
MeetingVO getDetail(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> items);
void completeRealtimeMeeting(Long meetingId, String audioUrl);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void updateMeetingParticipants(Long meetingId, String participants);
void updateSummaryContent(Long meetingId, String summaryContent);
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
} }

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -1,582 +1,11 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.unisbase.dto.PageResult;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service @Service
@RequiredArgsConstructor
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService { public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final AiTaskService aiTaskService;
private final AiTaskMapper aiTaskMapper;
private final MeetingTranscriptMapper transcriptMapper;
private final HotWordService hotWordService;
private final SysUserMapper sysUserMapper;
private final ApplicationEventPublisher eventPublisher;
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(MeetingDTO dto) {
Meeting meeting = initMeeting(dto, 0);
meeting.setAudioUrl(relocateAudioUrl(meeting.getId(), dto.getAudioUrl()));
this.updateById(meeting);
AiTask asrTask = new AiTask();
asrTask.setMeetingId(meeting.getId());
asrTask.setTaskType("ASR");
asrTask.setStatus(0);
Map<String, Object> asrConfig = new HashMap<>();
asrConfig.put("asrModelId", dto.getAsrModelId());
asrConfig.put("useSpkId", dto.getUseSpkId() != null ? dto.getUseSpkId() : 1);
List<String> finalHotWords = dto.getHotWords();
if (finalHotWords == null || finalHotWords.isEmpty()) {
finalHotWords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, meeting.getTenantId())
.eq(HotWord::getStatus, 1))
.stream()
.map(HotWord::getWord)
.collect(Collectors.toList());
}
asrConfig.put("hotWords", finalHotWords);
asrTask.setTaskConfig(asrConfig);
aiTaskService.save(asrTask);
createSummaryTask(meeting.getId(), dto.getSummaryModelId(), dto.getPromptId());
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
return toVO(meeting, false);
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(MeetingDTO dto) {
Meeting meeting = initMeeting(dto, 1);
createSummaryTask(meeting.getId(), dto.getSummaryModelId(), dto.getPromptId());
return toVO(meeting, false);
}
@Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getTenantId, tenantId);
if (!isAdmin || !"all".equals(viewType)) {
String userIdStr = String.valueOf(userId);
if ("created".equals(viewType)) {
wrapper.eq(Meeting::getCreatorId, userId);
} else if ("involved".equals(viewType)) {
wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr))
.ne(Meeting::getCreatorId, userId);
} else {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
}
if (title != null && !title.isEmpty()) {
wrapper.like(Meeting::getTitle, title);
}
wrapper.orderByDesc(Meeting::getCreatedAt);
Page<Meeting> page = this.page(new Page<>(current, size), wrapper);
List<MeetingVO> vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList());
PageResult<List<MeetingVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteMeeting(Long id) {
this.removeById(id);
}
@Override
public MeetingVO getDetail(Long id) {
Meeting meeting = this.getById(id);
return meeting != null ? toVO(meeting, true) : null;
}
@Override
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getStartTime))
.stream()
.map(t -> {
MeetingTranscriptVO vo = new MeetingTranscriptVO();
vo.setId(t.getId());
vo.setSpeakerId(t.getSpeakerId());
vo.setSpeakerName(t.getSpeakerName());
vo.setSpeakerLabel(t.getSpeakerLabel());
vo.setContent(t.getContent());
vo.setStartTime(t.getStartTime());
vo.setEndTime(t.getEndTime());
return vo;
}).collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> items) {
if (items == null || items.isEmpty()) {
return;
}
Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByDesc(MeetingTranscript::getSortOrder)
.last("LIMIT 1"))
.stream()
.findFirst()
.map(MeetingTranscript::getSortOrder)
.orElse(0);
int nextSortOrder = maxSortOrder == null ? 0 : maxSortOrder + 1;
for (RealtimeTranscriptItemDTO item : items) {
if (item.getContent() == null || item.getContent().isBlank()) {
continue;
}
MeetingTranscript existing = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.eq(MeetingTranscript::getContent, item.getContent().trim())
.eq(item.getSpeakerId() != null && !item.getSpeakerId().isBlank(), MeetingTranscript::getSpeakerId, item.getSpeakerId())
.eq(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime())
.eq(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime())
.last("LIMIT 1"));
if (existing != null) {
continue;
}
MeetingTranscript transcript = new MeetingTranscript();
transcript.setMeetingId(meetingId);
transcript.setSpeakerId(resolveSpeakerId(item));
transcript.setSpeakerName(resolveSpeakerName(item));
transcript.setContent(item.getContent().trim());
transcript.setStartTime(item.getStartTime());
transcript.setEndTime(item.getEndTime());
transcript.setSortOrder(nextSortOrder++);
transcriptMapper.insert(transcript);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void completeRealtimeMeeting(Long meetingId, String audioUrl) {
Meeting meeting = this.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
if (audioUrl != null && !audioUrl.isBlank()) {
meeting.setAudioUrl(relocateAudioUrl(meetingId, audioUrl));
this.updateById(meeting);
}
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId));
if (transcriptCount <= 0) {
meeting.setStatus(4);
this.updateById(meeting);
throw new RuntimeException("未接收到可用的实时转录内容");
}
aiTaskService.dispatchSummaryTask(meetingId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) {
transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.eq(MeetingTranscript::getSpeakerId, speakerId)
.set(newName != null, MeetingTranscript::getSpeakerName, newName)
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMeetingParticipants(Long meetingId, String participants) {
this.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId, meetingId)
.set(Meeting::getParticipants, participants == null ? "" : participants));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSummaryContent(Long meetingId, String summaryContent) {
Meeting meeting = this.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
AiTask summaryTask = findLatestSummaryTask(meeting);
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
throw new RuntimeException("Summary file not found");
}
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/"));
try {
Path parent = summaryPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
String existingContent = Files.exists(summaryPath) ? Files.readString(summaryPath, StandardCharsets.UTF_8) : "";
String frontMatter = extractFrontMatter(existingContent, meeting, summaryTask);
Files.writeString(summaryPath, frontMatter + normalizeSummaryMarkdown(summaryContent), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Update summary file failed", e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
Meeting meeting = this.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
createSummaryTask(meetingId, summaryModelId, promptId);
meeting.setStatus(2);
this.updateById(meeting);
aiTaskService.dispatchSummaryTask(meetingId);
}
@Override
public Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
Map<String, Object> stats = new HashMap<>();
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
String userIdStr = String.valueOf(userId);
baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
stats.put("totalMeetings", this.count(baseWrapper.clone()));
stats.put("processingTasks", this.count(baseWrapper.clone().in(Meeting::getStatus, 1, 2)));
LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
stats.put("todayNew", this.count(baseWrapper.clone().ge(Meeting::getCreatedAt, todayStart)));
long totalFinished = this.count(baseWrapper.clone().in(Meeting::getStatus, 3, 4));
long success = this.count(baseWrapper.clone().eq(Meeting::getStatus, 3));
stats.put("successRate", totalFinished == 0 ? 100 : (int) ((double) success / totalFinished * 100));
return stats;
}
@Override
public List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
String userIdStr = String.valueOf(userId);
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit);
return this.list(wrapper).stream().map(m -> toVO(m, false)).collect(Collectors.toList());
}
private void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId) {
AiTask sumTask = new AiTask();
sumTask.setMeetingId(meetingId);
sumTask.setTaskType("SUMMARY");
sumTask.setStatus(0);
Map<String, Object> sumConfig = new HashMap<>();
sumConfig.put("summaryModelId", summaryModelId);
if (promptId != null) {
PromptTemplate template = promptTemplateService.getById(promptId);
if (template != null) {
sumConfig.put("promptContent", template.getPromptContent());
}
}
sumTask.setTaskConfig(sumConfig);
aiTaskService.save(sumTask);
}
private Meeting initMeeting(MeetingDTO dto, int status) {
Meeting meeting = new Meeting();
meeting.setTitle(dto.getTitle());
meeting.setMeetingTime(dto.getMeetingTime());
meeting.setParticipants(dto.getParticipants());
meeting.setTags(dto.getTags());
meeting.setCreatorId(dto.getCreatorId());
meeting.setCreatorName(dto.getCreatorName());
meeting.setTenantId(dto.getTenantId() != null ? dto.getTenantId() : 0L);
meeting.setAudioUrl(dto.getAudioUrl());
meeting.setStatus(status);
this.save(meeting);
return meeting;
}
private String relocateAudioUrl(Long meetingId, String audioUrl) {
if (audioUrl == null || audioUrl.isBlank()) {
return audioUrl;
}
if (!audioUrl.startsWith("/api/static/audio/")) {
return audioUrl;
}
try {
String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1);
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path sourcePath = Paths.get(basePath, "audio", fileName);
if (!Files.exists(sourcePath)) {
return audioUrl;
}
String ext = "";
int dotIdx = fileName.lastIndexOf('.');
if (dotIdx > 0) {
ext = fileName.substring(dotIdx);
}
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId));
Files.createDirectories(targetDir);
Path targetPath = targetDir.resolve("source_audio" + ext);
Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
return "/api/static/meetings/" + meetingId + "/source_audio" + ext;
} catch (Exception e) {
log.error("Failed to move audio file for meeting {}", meetingId, e);
throw new RuntimeException("文件处理失败: " + e.getMessage());
}
}
private String resolveSpeakerId(RealtimeTranscriptItemDTO item) {
if (item.getSpeakerId() != null && !item.getSpeakerId().isBlank()) {
return item.getSpeakerId();
}
return "spk_0";
}
private String resolveSpeakerName(RealtimeTranscriptItemDTO item) {
if (item.getSpeakerName() != null && !item.getSpeakerName().isBlank()) {
return item.getSpeakerName();
}
String speakerId = resolveSpeakerId(item);
if (speakerId.matches("\\d+")) {
SysUser user = sysUserMapper.selectById(Long.parseLong(speakerId));
if (user != null) {
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
}
}
return speakerId;
}
private MeetingVO toVO(Meeting meeting, boolean includeSummary) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setDuration(resolveMeetingDuration(meeting.getId()));
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());
if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) {
try {
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Long::valueOf)
.collect(Collectors.toList());
vo.setParticipantIds(userIds);
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream()
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.collect(Collectors.joining(", "));
vo.setParticipants(names);
}
} catch (Exception e) {
vo.setParticipantIds(Collections.emptyList());
vo.setParticipants(meeting.getParticipants());
}
} else {
vo.setParticipantIds(Collections.emptyList());
}
if (includeSummary) {
vo.setSummaryContent(loadSummaryContent(meeting));
}
return vo;
}
private String loadSummaryContent(Meeting meeting) {
try {
AiTask summaryTask = findLatestSummaryTask(meeting);
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
return null;
}
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/"));
if (!Files.exists(summaryPath)) {
return null;
}
String content = Files.readString(summaryPath, StandardCharsets.UTF_8);
return stripFrontMatter(content);
} catch (Exception e) {
log.warn("Load summary content failed for meeting {}", meeting.getId(), e);
return null;
}
}
private AiTask findLatestSummaryTask(Meeting meeting) {
AiTask summaryTask = null;
if (meeting.getLatestSummaryTaskId() != null) {
summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId());
}
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.eq(AiTask::getStatus, 2)
.isNotNull(AiTask::getResultFilePath)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
return summaryTask;
}
private String extractFrontMatter(String markdown, Meeting meeting, AiTask summaryTask) {
if (markdown != null && markdown.startsWith("---")) {
int second = markdown.indexOf("\n---", 3);
if (second >= 0) {
int end = second + 4;
if (end < markdown.length() && markdown.charAt(end) == '\n') {
end++;
}
return markdown.substring(0, end);
}
}
return "---\n" +
"updatedAt: " + LocalDateTime.now() + "\n" +
"meetingId: " + meeting.getId() + "\n" +
"summaryTaskId: " + summaryTask.getId() + "\n" +
"---\n\n";
}
private String normalizeSummaryMarkdown(String markdown) {
if (markdown == null) {
return "";
}
String normalized = markdown.trim();
if (!normalized.startsWith("```")) {
return normalized;
}
int firstLineEnd = normalized.indexOf('\n');
if (firstLineEnd < 0) {
return normalized;
}
String firstLine = normalized.substring(0, firstLineEnd).trim().toLowerCase();
if (!"```".equals(firstLine) && !"```markdown".equals(firstLine) && !"```md".equals(firstLine)) {
return normalized;
}
int lastFence = normalized.lastIndexOf("\n```");
if (lastFence <= firstLineEnd) {
return normalized.substring(firstLineEnd + 1).trim();
}
return normalized.substring(firstLineEnd + 1, lastFence).trim();
}
private Integer resolveMeetingDuration(Long meetingId) {
MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.isNotNull(MeetingTranscript::getEndTime)
.orderByDesc(MeetingTranscript::getEndTime)
.last("LIMIT 1"));
if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) {
return latestTranscript.getEndTime();
}
latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.isNotNull(MeetingTranscript::getStartTime)
.orderByDesc(MeetingTranscript::getStartTime)
.last("LIMIT 1"));
if (latestTranscript != null && latestTranscript.getStartTime() != null && latestTranscript.getStartTime() > 0) {
return latestTranscript.getStartTime();
}
return null;
}
private String stripFrontMatter(String markdown) {
if (markdown == null || markdown.isBlank()) {
return markdown;
}
if (!markdown.startsWith("---")) {
return normalizeSummaryMarkdown(markdown);
}
int second = markdown.indexOf("\n---", 3);
if (second < 0) {
return normalizeSummaryMarkdown(markdown);
}
int contentStart = second + 4;
if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') {
contentStart++;
}
return normalizeSummaryMarkdown(markdown.substring(contentStart).trim());
}
} }

View File

@ -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();
}
}

View File

@ -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/}

View File

@ -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}

View File

@ -1,68 +1,27 @@
server: server:
port: 8080 port: ${SERVER_PORT:8082}
spring: spring:
datasource: datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.51.199:5432/imeeting_db} url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_test}
username: ${SPRING_DATASOURCE_USERNAME:postgres} username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres} password: ${SPRING_DATASOURCE_PASSWORD:postgres}
data: data:
redis: redis:
host: ${SPRING_DATA_REDIS_HOST:10.100.51.199} host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
port: ${SPRING_DATA_REDIS_PORT:6379} port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:unis@123} password: ${SPRING_DATA_REDIS_PASSWORD:}
database: ${SPRING_DATA_REDIS_DATABASE:15} database: ${SPRING_DATA_REDIS_DATABASE:16}
cache:
type: redis
servlet:
multipart:
max-file-size: 2048MB
max-request-size: 2048MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
serialization:
write-dates-as-timestamps: false
time-zone: GMT+8
mybatis-plus: mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
unisbase: unisbase:
web:
auth-endpoints-enabled: true
management-endpoints-enabled: true
tenant:
ignoreTables:
- biz_ai_tasks
- biz_meeting_transcripts
- biz_speakers
security: security:
enabled: true jwt-secret: ${SECURITY_JWT_SECRET:change-me-test-jwt-secret-32bytes}
mode: embedded
jwt-secret: ${SECURITY_JWT_SECRET:change-me-please-change-me-32bytes}
auth-header: Authorization
token-prefix: "Bearer "
permit-all-urls:
- /actuator/health
- /api/static/**
internal-auth: internal-auth:
enabled: true secret: ${INTERNAL_AUTH_SECRET:change-me-test-internal-secret}
secret: change-me-internal-secret
header-name: X-Internal-Secret
app: app:
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:8080} # 本地应用对外暴露的 IP 和端口 server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/}
resource-prefix: /api/static/
captcha:
ttl-seconds: 120
max-attempts: 5
token:
access-default-minutes: 30
refresh-default-days: 7

View File

@ -1,24 +1,15 @@
server: server:
port: 8081 port: ${SERVER_PORT:8080}
spring: spring:
datasource: profiles:
url: jdbc:postgresql://10.100.51.199:5432/imeeting_db active: ${SPRING_PROFILES_ACTIVE:dev}
username: postgres
password: postgres
data:
redis:
host: 10.100.51.199
port: 6379
password: unis@123
database: 15
cache: cache:
type: redis type: redis
servlet: servlet:
multipart: multipart:
max-file-size: 2048MB max-file-size: 2048MB
max-request-size: 2048MB max-request-size: 2048MB
jackson: jackson:
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss
serialization: serialization:
@ -28,7 +19,6 @@ spring:
mybatis-plus: mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
logic-delete-field: isDeleted logic-delete-field: isDeleted
@ -47,7 +37,6 @@ unisbase:
security: security:
enabled: true enabled: true
mode: embedded mode: embedded
jwt-secret: change-me-please-change-me-32bytes
auth-header: Authorization auth-header: Authorization
token-prefix: "Bearer " token-prefix: "Bearer "
permit-all-urls: permit-all-urls:
@ -55,11 +44,8 @@ unisbase:
- /api/static/** - /api/static/**
internal-auth: internal-auth:
enabled: true enabled: true
secret: change-me-internal-secret
header-name: X-Internal-Secret header-name: X-Internal-Secret
app: app:
server-base-url: http://10.100.52.13:${server.port} # 本地应用对外暴露的 IP 和端口
upload-path: D:/data/imeeting/uploads/
resource-prefix: /api/static/ resource-prefix: /api/static/
captcha: captcha:
ttl-seconds: 120 ttl-seconds: 120

View File

@ -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();
}
}
}

View File

@ -17,7 +17,7 @@ export interface MeetingVO {
createdAt: string; createdAt: string;
} }
export interface MeetingDTO { export interface CreateMeetingCommand {
id?: number; id?: number;
title: string; title: string;
meetingTime: string; meetingTime: string;
@ -31,6 +31,24 @@ export interface MeetingDTO {
hotWords?: string[]; hotWords?: string[];
} }
export type MeetingDTO = CreateMeetingCommand;
export interface UpdateMeetingBasicCommand {
meetingId: number;
title?: string;
meetingTime?: string;
tags?: string;
}
export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand;
export interface UpdateMeetingSummaryCommand {
meetingId: number;
summaryContent: string;
}
export type MeetingUpdateSummaryDTO = UpdateMeetingSummaryCommand;
export const getMeetingPage = (params: { export const getMeetingPage = (params: {
current: number; current: number;
size: number; size: number;
@ -43,7 +61,7 @@ export const getMeetingPage = (params: {
); );
}; };
export const createMeeting = (data: MeetingDTO) => { export const createMeeting = (data: CreateMeetingCommand) => {
return http.post<any, { code: string; data: MeetingVO; msg: string }>( return http.post<any, { code: string; data: MeetingVO; msg: string }>(
"/api/biz/meeting", "/api/biz/meeting",
data data
@ -58,7 +76,7 @@ export interface RealtimeTranscriptItemDTO {
endTime?: number; endTime?: number;
} }
export const createRealtimeMeeting = (data: MeetingDTO) => { export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
return http.post<any, { code: string; data: MeetingVO; msg: string }>( return http.post<any, { code: string; data: MeetingVO; msg: string }>(
"/api/biz/meeting/realtime/start", "/api/biz/meeting/realtime/start",
data data
@ -97,40 +115,67 @@ export interface MeetingTranscriptVO {
export const getMeetingDetail = (id: number) => { export const getMeetingDetail = (id: number) => {
return http.get<any, { code: string; data: MeetingVO; msg: string }>( return http.get<any, { code: string; data: MeetingVO; msg: string }>(
`/api/biz/meeting/detail/${id}` `/api/biz/meeting/${id}`
); );
}; };
export const getTranscripts = (id: number) => { export const getTranscripts = (id: number) => {
return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>( return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>(
`/api/biz/meeting/transcripts/${id}` `/api/biz/meeting/${id}/transcripts`
); );
}; };
export const updateSpeakerInfo = (params: { meetingId: number; speakerId: string; newName: string; label: string }) => { export interface MeetingSpeakerUpdateDTO {
meetingId: number;
speakerId: string;
newName: string;
label: string;
}
export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
return http.put<any, { code: string; data: boolean; msg: string }>( return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/speaker", "/api/biz/meeting/speaker",
params params
); );
}; };
export const reSummary = (params: { meetingId: number; summaryModelId: number; promptId: number }) => { export interface MeetingResummaryDTO {
meetingId: number;
summaryModelId: number;
promptId: number;
}
export const reSummary = (params: MeetingResummaryDTO) => {
return http.post<any, { code: string; data: boolean; msg: string }>( return http.post<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/re-summary", `/api/biz/meeting/${params.meetingId}/summary/regenerate`,
params params
); );
}; };
export const updateMeeting = (data: Partial<MeetingVO>) => { export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
return http.put<any, { code: string; data: boolean; msg: string }>( return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting", `/api/biz/meeting/${data.meetingId}/basic`,
data data
); );
}; };
export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => { export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => {
return http.put<any, { code: string; data: boolean; msg: string }>( return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/participants", `/api/biz/meeting/${data.meetingId}/summary`,
data
);
};
export interface UpdateMeetingParticipantsCommand {
meetingId: number;
participants: string;
}
export type MeetingParticipantsUpdateDTO = UpdateMeetingParticipantsCommand;
export const updateMeetingParticipants = (params: UpdateMeetingParticipantsCommand) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${params.meetingId}/participants`,
params params
); );
}; };

View File

@ -19,6 +19,7 @@ export function useAuth() {
const logout = () => { const logout = () => {
localStorage.removeItem("accessToken"); localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken"); localStorage.removeItem("refreshToken");
localStorage.removeItem("displayName");
sessionStorage.removeItem("userProfile"); sessionStorage.removeItem("userProfile");
setAccessToken(null); setAccessToken(null);
}; };

View File

@ -1,31 +1,70 @@
:root { :root {
--app-primary-color: #1677ff; --app-primary-color: #1677ff;
--app-bg-main: radial-gradient(circle at top, rgba(56, 154, 255, 0.08), transparent 26%), linear-gradient(180deg, #f3f7fb 0%, #eef3f8 100%); --app-primary-rgb: 22, 119, 255;
--app-bg-card: rgba(255, 255, 255, 0.92); --app-bg-main:
radial-gradient(circle at 12% 18%, rgba(136, 161, 255, 0.18), transparent 22%),
radial-gradient(circle at 84% 14%, rgba(131, 217, 255, 0.2), transparent 24%),
radial-gradient(circle at 68% 78%, rgba(255, 207, 228, 0.12), transparent 20%),
linear-gradient(180deg, #fcfdff 0%, #f6f9ff 38%, #eff4fb 100%);
--app-bg-overlay:
linear-gradient(rgba(255, 255, 255, 0.34) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.34) 1px, transparent 1px);
--app-bg-overlay-size: 36px 36px;
--app-bg-card: rgba(255, 255, 255, 0.74);
--app-text-main: #1f2937; --app-text-main: #1f2937;
--app-border-color: rgba(15, 93, 166, 0.06); --app-border-color: rgba(103, 126, 189, 0.12);
--app-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); --app-shadow: 0 18px 40px rgba(100, 118, 171, 0.1);
--app-bg-page: rgba(255, 255, 255, 0.18);
--app-bg-surface-soft: rgba(255, 255, 255, 0.56);
--app-bg-surface-strong: rgba(255, 255, 255, 0.82);
--app-text-muted: #66758f;
} }
:root[data-theme="minimal"] { :root[data-theme="minimal"] {
--app-bg-main: #f9fafb; --app-bg-main:
--app-bg-card: #ffffff; radial-gradient(circle at 14% 18%, rgba(222, 229, 241, 0.42), transparent 20%),
linear-gradient(180deg, #fcfcfd 0%, #f5f7fb 100%);
--app-bg-overlay:
linear-gradient(rgba(255, 255, 255, 0.24) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.24) 1px, transparent 1px);
--app-bg-card: rgba(255, 255, 255, 0.82);
--app-text-main: #111827; --app-text-main: #111827;
--app-border-color: #e5e7eb; --app-border-color: rgba(148, 163, 184, 0.16);
--app-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --app-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
--app-bg-page: rgba(255, 255, 255, 0.16);
--app-bg-surface-soft: rgba(255, 255, 255, 0.62);
--app-bg-surface-strong: rgba(255, 255, 255, 0.86);
--app-text-muted: #5b6474;
} }
:root[data-theme="tech"] { :root[data-theme="tech"] {
--app-bg-main: radial-gradient(circle at 50% 0%, rgba(22, 119, 255, 0.15), transparent 40%), #0d1117; --app-bg-main:
--app-bg-card: rgba(30, 41, 59, 0.7); radial-gradient(circle at 20% 20%, rgba(52, 144, 255, 0.2), transparent 18%),
radial-gradient(circle at 80% 18%, rgba(47, 211, 255, 0.14), transparent 20%),
linear-gradient(180deg, #08101c 0%, #0d1526 54%, #101b30 100%);
--app-bg-overlay:
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
--app-bg-card: rgba(13, 23, 39, 0.62);
--app-text-main: #e2e8f0; --app-text-main: #e2e8f0;
--app-border-color: rgba(22, 119, 255, 0.2); --app-border-color: rgba(88, 151, 255, 0.18);
--app-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); --app-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
--app-bg-page: rgba(5, 12, 24, 0.22);
--app-bg-surface-soft: rgba(10, 21, 37, 0.72);
--app-bg-surface-strong: rgba(8, 17, 31, 0.88);
--app-text-muted: rgba(190, 206, 229, 0.74);
}
html {
min-height: 100%;
background: #f7faff;
} }
body { body {
position: relative;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh;
background: var(--app-bg-main); background: var(--app-bg-main);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
@ -34,6 +73,33 @@ body {
transition: background 0.3s ease, color 0.3s ease; transition: background 0.3s ease, color 0.3s ease;
} }
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
body::before {
z-index: -3;
background:
radial-gradient(circle at 18% 24%, rgba(145, 167, 255, 0.22) 0%, rgba(145, 167, 255, 0) 28%),
radial-gradient(circle at 78% 18%, rgba(121, 221, 255, 0.2) 0%, rgba(121, 221, 255, 0) 24%),
radial-gradient(circle at 62% 74%, rgba(255, 209, 227, 0.16) 0%, rgba(255, 209, 227, 0) 22%);
filter: blur(6px);
}
body::after {
inset: 18px;
z-index: -2;
border-radius: 28px;
background-image: var(--app-bg-overlay);
background-size: var(--app-bg-overlay-size);
opacity: 0.46;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.42), transparent 84%);
}
.ant-layout { .ant-layout {
background: transparent !important; background: transparent !important;
} }
@ -41,6 +107,7 @@ body {
.ant-layout-sider { .ant-layout-sider {
background: var(--app-bg-card) !important; background: var(--app-bg-card) !important;
border-right: 1px solid var(--app-border-color); border-right: 1px solid var(--app-border-color);
backdrop-filter: blur(16px);
transition: background 0.3s ease; transition: background 0.3s ease;
} }
@ -53,31 +120,66 @@ body {
color: var(--app-text-main) !important; color: var(--app-text-main) !important;
} }
/* Sider animation refinement */
.app-sider .ant-layout-sider-children { .app-sider .ant-layout-sider-children {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Scrollbar styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #ccc; background: rgba(151, 163, 184, 0.8);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: rgba(241, 245, 249, 0.7);
} }
#root { #root {
position: relative;
min-height: 100vh; min-height: 100vh;
} }
#root::before,
#root::after {
content: "";
position: fixed;
pointer-events: none;
}
#root::before {
inset: 0;
z-index: -1;
background:
radial-gradient(120% 48px at 8% 72%, rgba(124, 142, 255, 0.22) 0%, rgba(124, 142, 255, 0.08) 32%, rgba(124, 142, 255, 0) 58%),
radial-gradient(120% 42px at 56% 76%, rgba(96, 209, 255, 0.18) 0%, rgba(96, 209, 255, 0.08) 30%, rgba(96, 209, 255, 0) 56%),
radial-gradient(120% 54px at 90% 70%, rgba(160, 151, 255, 0.18) 0%, rgba(160, 151, 255, 0.08) 34%, rgba(160, 151, 255, 0) 60%);
background-repeat: no-repeat;
opacity: 0.9;
}
#root::after {
left: 0;
right: 0;
bottom: 8%;
height: 180px;
z-index: -1;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.08) 100%),
repeating-linear-gradient(
90deg,
rgba(128, 147, 255, 0.04) 0,
rgba(128, 147, 255, 0.04) 6px,
transparent 6px,
transparent 22px
);
mask-image: radial-gradient(120% 90% at 50% 100%, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.38) 54%, transparent 82%);
}
.app-page { .app-page {
height: 100%; height: 100%;
padding: 24px; padding: 24px;
@ -98,7 +200,7 @@ body {
border-radius: 16px !important; border-radius: 16px !important;
box-shadow: var(--app-shadow); box-shadow: var(--app-shadow);
background: var(--app-bg-card); background: var(--app-bg-card);
backdrop-filter: blur(10px); backdrop-filter: blur(16px);
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
} }
@ -165,17 +267,488 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.78); background: rgba(255, 255, 255, 0.66);
border-radius: 16px; border-radius: 16px;
border: 1px dashed rgba(148, 163, 184, 0.5); border: 1px dashed rgba(148, 163, 184, 0.4);
backdrop-filter: blur(12px);
} }
.tabular-nums { .tabular-nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
:root[data-theme="default"] .home-landing {
--home-primary-rgb: 103, 103, 244;
--home-primary: #6767f4;
--home-title-color: #272554;
--home-body-color: #5d678c;
--home-muted-color: #9198b2;
--home-surface-strong: rgba(255, 255, 255, 0.92);
--home-surface: rgba(247, 246, 255, 0.84);
--home-surface-soft: rgba(255, 255, 255, 0.74);
--home-border-strong: rgba(214, 205, 255, 0.96);
--home-border: rgba(233, 228, 255, 0.96);
--home-shadow: 0 22px 48px rgba(141, 132, 223, 0.14);
background:
radial-gradient(circle at 14% 12%, rgba(170, 146, 255, 0.04), transparent 18%),
radial-gradient(circle at 82% 16%, rgba(165, 214, 255, 0.05), transparent 24%),
radial-gradient(circle at 62% 74%, rgba(255, 206, 232, 0.03), transparent 16%),
linear-gradient(180deg, #ffffff 0%, #ffffff 46%, #fefeff 100%) !important;
}
:root[data-theme="default"] .home-landing__halo--large {
background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.18) 0%, rgba(var(--home-primary-rgb), 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important;
}
:root[data-theme="default"] .home-landing__halo--small {
background: radial-gradient(circle, rgba(var(--home-primary-rgb), 0.16) 0%, rgba(var(--home-primary-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important;
}
:root[data-theme="default"] .home-landing__eyebrow,
:root[data-theme="default"] .home-landing__status-item,
:root[data-theme="default"] .home-landing__visual-frame,
:root[data-theme="default"] .home-landing__soundstage,
:root[data-theme="default"] .home-landing__board-panel,
:root[data-theme="default"] .home-landing__board-stat,
:root[data-theme="default"] .home-recent-card {
border-color: var(--home-border) !important;
box-shadow: var(--home-shadow) !important;
}
:root[data-theme="default"] .home-landing__eyebrow,
:root[data-theme="default"] .home-landing__status-item,
:root[data-theme="default"] .home-landing__visual-chip,
:root[data-theme="default"] .home-landing__visual-frame,
:root[data-theme="default"] .home-landing__soundstage,
:root[data-theme="default"] .home-landing__board-panel,
:root[data-theme="default"] .home-landing__board-stat,
:root[data-theme="default"] .home-recent-card,
:root[data-theme="default"] .home-landing__empty {
background: linear-gradient(180deg, var(--home-surface-strong), var(--home-surface)) !important;
}
:root[data-theme="default"] .home-landing__eyebrow,
:root[data-theme="default"] .home-landing__visual-chip,
:root[data-theme="default"] .home-landing__board-pill,
:root[data-theme="default"] .home-entry-card__cta,
:root[data-theme="default"] .home-entry-card:hover .home-entry-card__cta {
color: var(--home-primary) !important;
}
:root[data-theme="default"] .home-landing__title,
:root[data-theme="default"] .home-entry-card h3,
:root[data-theme="default"] .home-landing__section-head h3,
:root[data-theme="default"] .home-recent-card__head h4 {
color: var(--home-title-color) !important;
}
:root[data-theme="default"] .home-landing__title span {
color: var(--home-primary) !important;
}
:root[data-theme="default"] .home-landing__status-item,
:root[data-theme="default"] .home-entry-card__line,
:root[data-theme="default"] .home-recent-card__tags .ant-tag {
color: var(--home-body-color) !important;
}
:root[data-theme="default"] .home-recent-card__foot,
:root[data-theme="default"] .home-recent-card__head .anticon {
color: var(--home-muted-color) !important;
}
:root[data-theme="default"] .home-entry-card,
:root[data-theme="default"] .home-entry-card--violet,
:root[data-theme="default"] .home-entry-card--cyan {
border-color: var(--home-border) !important;
box-shadow: 0 18px 40px rgba(var(--home-primary-rgb), 0.14) !important;
}
:root[data-theme="default"] .home-entry-card--violet {
background:
linear-gradient(180deg, rgba(252, 248, 255, 0.98) 0%, rgba(240, 234, 255, 0.92) 100%),
linear-gradient(135deg, rgba(212, 189, 255, 0.28), rgba(214, 228, 255, 0.12)) !important;
}
:root[data-theme="default"] .home-entry-card--cyan {
background:
linear-gradient(180deg, rgba(244, 254, 255, 0.98) 0%, rgba(231, 249, 255, 0.92) 100%),
linear-gradient(135deg, rgba(159, 233, 255, 0.28), rgba(202, 233, 255, 0.1)) !important;
}
:root[data-theme="default"] .home-entry-card:focus-visible {
outline-color: rgba(var(--home-primary-rgb), 0.34) !important;
}
:root[data-theme="default"] .home-entry-card:hover,
:root[data-theme="default"] .home-recent-card:hover {
border-color: var(--home-border-strong) !important;
box-shadow: 0 24px 48px rgba(var(--home-primary-rgb), 0.18) !important;
}
:root[data-theme="default"] .home-entry-card__icon,
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__icon {
background: linear-gradient(135deg, rgba(var(--home-primary-rgb), 0.96) 0%, rgba(var(--home-primary-rgb), 0.48) 100%) !important;
box-shadow: 0 18px 34px rgba(var(--home-primary-rgb), 0.26) !important;
}
:root[data-theme="default"] .home-entry-card--violet .home-entry-card__icon {
background: linear-gradient(135deg, #7569f2 0%, #9bb7ff 100%) !important;
box-shadow: 0 18px 34px rgba(112, 103, 212, 0.24) !important;
}
:root[data-theme="default"] .home-entry-card__badge,
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge,
:root[data-theme="default"] .home-recent-card__tags .ant-tag {
border-color: rgba(var(--home-primary-rgb), 0.18) !important;
background: rgba(var(--home-primary-rgb), 0.1) !important;
}
:root[data-theme="default"] .home-entry-card--violet .home-entry-card__badge {
border-color: rgba(193, 176, 255, 0.24) !important;
background: rgba(193, 176, 255, 0.24) !important;
color: #695fd2 !important;
}
:root[data-theme="default"] .home-entry-card__badge {
color: color-mix(in srgb, var(--home-primary) 72%, var(--home-title-color)) !important;
}
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__badge {
border-color: rgba(131, 220, 244, 0.22) !important;
background: rgba(131, 220, 244, 0.22) !important;
color: #3a9fc5 !important;
}
:root[data-theme="default"] .home-entry-card__track span,
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__track span,
:root[data-theme="default"] .home-landing__visual-waveform span,
:root[data-theme="default"] .home-landing__board-bars span,
:root[data-theme="default"] .home-landing__board-line {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(var(--home-primary-rgb), 0.62)) !important;
box-shadow: 0 8px 18px rgba(var(--home-primary-rgb), 0.18) !important;
}
:root[data-theme="default"] .home-entry-card__pulse,
:root[data-theme="default"] .home-entry-card--cyan .home-entry-card__pulse,
:root[data-theme="default"] .home-landing__board-glow {
background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(var(--home-primary-rgb), 0.34) 36%, rgba(var(--home-primary-rgb), 0.08) 72%, transparent 76%) !important;
}
:root[data-theme="default"] .home-landing__visual-grid,
:root[data-theme="default"] .home-landing__board-grid {
background-image:
linear-gradient(rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.08) 1px, transparent 1px) !important;
}
:root[data-theme="default"] .home-landing__visual-radar {
background:
radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.99) 0%, rgba(243, 244, 255, 0.94) 26%, rgba(var(--home-primary-rgb), 0.34) 48%, rgba(255, 255, 255, 0.04) 76%),
linear-gradient(145deg, rgba(var(--home-primary-rgb), 0.28), rgba(242, 246, 255, 0.12)) !important;
box-shadow:
inset 0 0 54px rgba(255, 255, 255, 0.82),
0 18px 38px rgba(var(--home-primary-rgb), 0.14) !important;
}
:root[data-theme="default"] .home-landing__visual-radar::before,
:root[data-theme="default"] .home-landing__visual-radar::after,
:root[data-theme="default"] .home-landing__board-node,
:root[data-theme="default"] .home-landing__board-node--active,
:root[data-theme="default"] .home-landing__board-rail {
border-color: rgba(var(--home-primary-rgb), 0.22) !important;
}
:root[data-theme="default"] .home-landing__board-node {
background: color-mix(in srgb, var(--home-surface-strong) 92%, #ffffff) !important;
}
:root[data-theme="default"] .home-landing__board-node--active {
border-color: rgba(var(--home-primary-rgb), 0.88) !important;
box-shadow: 0 0 0 8px rgba(var(--home-primary-rgb), 0.2) !important;
}
:root[data-theme="default"] .home-landing__board-rail {
background: linear-gradient(90deg, rgba(var(--home-primary-rgb), 0.16), rgba(var(--home-primary-rgb), 0.62), rgba(var(--home-primary-rgb), 0.24)) !important;
}
:root[data-theme="default"] .home-recent-card__pin {
background: var(--home-primary) !important;
box-shadow: 0 0 0 6px rgba(var(--home-primary-rgb), 0.14) !important;
}
:root[data-theme="default"] .home-landing__soundstage {
border-color: rgba(180, 206, 255, 0.36) !important;
background:
linear-gradient(180deg, rgba(237, 245, 255, 0.96), rgba(205, 224, 255, 0.9)),
linear-gradient(135deg, rgba(95, 138, 255, 0.3), rgba(121, 194, 255, 0.16) 56%, rgba(255, 255, 255, 0)) !important;
box-shadow:
0 24px 52px rgba(95, 138, 255, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.92) !important;
}
:root[data-theme="default"] .home-landing__soundstage::before {
border-color: rgba(182, 210, 255, 0.72) !important;
}
:root[data-theme="default"] .home-landing__board-panel,
:root[data-theme="default"] .home-landing__board-stat {
border-color: rgba(160, 192, 255, 0.56) !important;
background:
linear-gradient(180deg, rgba(241, 248, 255, 0.96), rgba(215, 230, 255, 0.92)),
linear-gradient(135deg, rgba(91, 129, 240, 0.2), rgba(125, 203, 255, 0.08)) !important;
box-shadow:
0 16px 30px rgba(89, 126, 226, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
}
:root[data-theme="default"] .home-landing__board-pill {
background: linear-gradient(90deg, rgba(111, 151, 255, 0.24), rgba(150, 219, 255, 0.2)) !important;
color: #3b67d6 !important;
}
:root[data-theme="default"] .home-landing__board-line,
:root[data-theme="default"] .home-landing__board-bars span,
:root[data-theme="default"] .home-landing__board-rail {
background: linear-gradient(90deg, rgba(74, 116, 226, 0.92), rgba(99, 161, 255, 0.62), rgba(150, 219, 255, 0.34)) !important;
box-shadow: 0 10px 18px rgba(88, 117, 214, 0.14) !important;
}
:root[data-theme="default"] .home-landing__board-glow {
background: radial-gradient(circle, rgba(121, 175, 255, 0.52) 0%, rgba(118, 199, 255, 0.28) 42%, rgba(214, 226, 239, 0) 74%) !important;
}
:root[data-theme="default"] .home-landing__board-node {
border-color: rgba(95, 138, 255, 0.44) !important;
}
:root[data-theme="default"] .home-landing__board-node--active {
border-color: rgba(56, 97, 218, 0.92) !important;
box-shadow: 0 0 0 8px rgba(110, 155, 255, 0.2) !important;
}
:root[data-theme="tech"] .home-landing {
--home-tech-rgb: var(--app-primary-rgb);
--home-tech-primary: var(--app-primary-color);
--home-tech-title: #eef4ff;
--home-tech-body: rgba(214, 225, 243, 0.84);
--home-tech-muted: rgba(167, 185, 214, 0.82);
--home-tech-surface-strong: rgba(10, 18, 32, 0.9);
--home-tech-surface: rgba(12, 22, 38, 0.76);
--home-tech-surface-soft: rgba(14, 26, 44, 0.62);
--home-tech-border: rgba(var(--home-tech-rgb), 0.22);
--home-tech-border-strong: rgba(var(--home-tech-rgb), 0.34);
--home-tech-shadow: 0 24px 58px rgba(0, 0, 0, 0.32);
background:
radial-gradient(circle at 14% 12%, rgba(var(--home-tech-rgb), 0.18), transparent 18%),
radial-gradient(circle at 82% 16%, rgba(47, 211, 255, 0.14), transparent 24%),
radial-gradient(circle at 62% 74%, rgba(var(--home-tech-rgb), 0.12), transparent 16%),
linear-gradient(180deg, #08101c 0%, #0c1527 46%, #0f1b30 100%) !important;
}
:root[data-theme="tech"] .home-landing__halo--large {
background: radial-gradient(circle, rgba(var(--home-tech-rgb), 0.2) 0%, rgba(47, 211, 255, 0.08) 52%, rgba(255, 255, 255, 0) 80%) !important;
}
:root[data-theme="tech"] .home-landing__halo--small {
background: radial-gradient(circle, rgba(47, 211, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.05) 46%, rgba(255, 255, 255, 0) 76%) !important;
}
:root[data-theme="tech"] .home-landing__eyebrow,
:root[data-theme="tech"] .home-landing__status-item,
:root[data-theme="tech"] .home-landing__visual-frame,
:root[data-theme="tech"] .home-landing__soundstage,
:root[data-theme="tech"] .home-landing__board-panel,
:root[data-theme="tech"] .home-landing__board-stat,
:root[data-theme="tech"] .home-recent-card {
border-color: var(--home-tech-border) !important;
box-shadow: var(--home-tech-shadow) !important;
}
:root[data-theme="tech"] .home-landing__eyebrow,
:root[data-theme="tech"] .home-landing__status-item,
:root[data-theme="tech"] .home-landing__visual-chip,
:root[data-theme="tech"] .home-landing__visual-frame,
:root[data-theme="tech"] .home-landing__soundstage,
:root[data-theme="tech"] .home-landing__board-panel,
:root[data-theme="tech"] .home-landing__board-stat,
:root[data-theme="tech"] .home-recent-card,
:root[data-theme="tech"] .home-landing__empty {
background:
linear-gradient(180deg, rgba(11, 21, 36, 0.94), rgba(14, 26, 44, 0.78)),
linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.12), rgba(47, 211, 255, 0.06)) !important;
}
:root[data-theme="tech"] .home-landing__eyebrow,
:root[data-theme="tech"] .home-landing__visual-chip,
:root[data-theme="tech"] .home-landing__board-pill,
:root[data-theme="tech"] .home-entry-card__cta,
:root[data-theme="tech"] .home-entry-card:hover .home-entry-card__cta {
color: var(--home-tech-primary) !important;
}
:root[data-theme="tech"] .home-landing__title,
:root[data-theme="tech"] .home-entry-card h3,
:root[data-theme="tech"] .home-landing__section-head h3,
:root[data-theme="tech"] .home-recent-card__head h4 {
color: var(--home-tech-title) !important;
}
:root[data-theme="tech"] .home-landing__title span {
color: var(--home-tech-primary) !important;
}
:root[data-theme="tech"] .home-landing__status-item,
:root[data-theme="tech"] .home-entry-card__line,
:root[data-theme="tech"] .home-recent-card__tags .ant-tag {
color: var(--home-tech-body) !important;
}
:root[data-theme="tech"] .home-recent-card__foot,
:root[data-theme="tech"] .home-recent-card__head .anticon {
color: var(--home-tech-muted) !important;
}
:root[data-theme="tech"] .home-entry-card,
:root[data-theme="tech"] .home-entry-card--violet,
:root[data-theme="tech"] .home-entry-card--cyan {
border-color: var(--home-tech-border) !important;
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28) !important;
}
:root[data-theme="tech"] .home-entry-card--violet {
background:
linear-gradient(180deg, rgba(14, 22, 40, 0.96) 0%, rgba(22, 28, 52, 0.88) 100%),
linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.26), rgba(120, 126, 255, 0.08)) !important;
}
:root[data-theme="tech"] .home-entry-card--cyan {
background:
linear-gradient(180deg, rgba(10, 24, 38, 0.96) 0%, rgba(14, 34, 46, 0.88) 100%),
linear-gradient(135deg, rgba(47, 211, 255, 0.22), rgba(var(--home-tech-rgb), 0.08)) !important;
}
:root[data-theme="tech"] .home-entry-card:focus-visible {
outline-color: rgba(var(--home-tech-rgb), 0.42) !important;
}
:root[data-theme="tech"] .home-entry-card:hover,
:root[data-theme="tech"] .home-recent-card:hover {
border-color: var(--home-tech-border-strong) !important;
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.34) !important;
}
:root[data-theme="tech"] .home-entry-card__icon {
background: linear-gradient(135deg, rgba(var(--home-tech-rgb), 0.96) 0%, rgba(136, 178, 255, 0.62) 100%) !important;
box-shadow: 0 18px 34px rgba(var(--home-tech-rgb), 0.28) !important;
}
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__icon {
background: linear-gradient(135deg, #1fb6d9 0%, #72dfff 100%) !important;
box-shadow: 0 18px 34px rgba(47, 211, 255, 0.24) !important;
}
:root[data-theme="tech"] .home-entry-card__badge,
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge,
:root[data-theme="tech"] .home-recent-card__tags .ant-tag {
border-color: rgba(var(--home-tech-rgb), 0.2) !important;
background: rgba(var(--home-tech-rgb), 0.12) !important;
}
:root[data-theme="tech"] .home-entry-card__badge {
color: rgba(188, 202, 255, 0.92) !important;
}
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__badge {
border-color: rgba(47, 211, 255, 0.2) !important;
background: rgba(47, 211, 255, 0.12) !important;
color: rgba(151, 236, 255, 0.92) !important;
}
:root[data-theme="tech"] .home-entry-card__track span,
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span,
:root[data-theme="tech"] .home-landing__visual-waveform span,
:root[data-theme="tech"] .home-landing__board-bars span,
:root[data-theme="tech"] .home-landing__board-line {
background: linear-gradient(180deg, rgba(233, 241, 255, 0.96), rgba(var(--home-tech-rgb), 0.5)) !important;
box-shadow: 0 8px 18px rgba(var(--home-tech-rgb), 0.2) !important;
}
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__track span,
:root[data-theme="tech"] .home-landing__board-bars span {
background: linear-gradient(180deg, rgba(233, 247, 255, 0.92), rgba(47, 211, 255, 0.54)) !important;
box-shadow: 0 8px 18px rgba(47, 211, 255, 0.22) !important;
}
:root[data-theme="tech"] .home-entry-card__pulse,
:root[data-theme="tech"] .home-entry-card--cyan .home-entry-card__pulse,
:root[data-theme="tech"] .home-landing__board-glow {
background: radial-gradient(circle, rgba(255, 255, 255, 0.16) 0%, rgba(var(--home-tech-rgb), 0.26) 36%, rgba(var(--home-tech-rgb), 0.08) 72%, transparent 76%) !important;
}
:root[data-theme="tech"] .home-landing__visual-grid,
:root[data-theme="tech"] .home-landing__board-grid {
background-image:
linear-gradient(rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.08) 1px, transparent 1px) !important;
}
:root[data-theme="tech"] .home-landing__visual-radar {
background:
radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.18) 0%, rgba(88, 133, 230, 0.26) 26%, rgba(var(--home-tech-rgb), 0.28) 48%, rgba(255, 255, 255, 0.02) 76%),
linear-gradient(145deg, rgba(var(--home-tech-rgb), 0.22), rgba(47, 211, 255, 0.08)) !important;
box-shadow:
inset 0 0 54px rgba(255, 255, 255, 0.06),
0 18px 38px rgba(0, 0, 0, 0.24) !important;
}
:root[data-theme="tech"] .home-landing__visual-radar::before,
:root[data-theme="tech"] .home-landing__visual-radar::after,
:root[data-theme="tech"] .home-landing__board-node,
:root[data-theme="tech"] .home-landing__board-node--active,
:root[data-theme="tech"] .home-landing__board-rail {
border-color: rgba(var(--home-tech-rgb), 0.22) !important;
}
:root[data-theme="tech"] .home-landing__board-node {
background: rgba(8, 17, 31, 0.96) !important;
}
:root[data-theme="tech"] .home-landing__board-node--active {
border-color: rgba(var(--home-tech-rgb), 0.9) !important;
box-shadow: 0 0 0 8px rgba(var(--home-tech-rgb), 0.16) !important;
}
:root[data-theme="tech"] .home-landing__board-rail {
background: linear-gradient(90deg, rgba(var(--home-tech-rgb), 0.16), rgba(var(--home-tech-rgb), 0.62), rgba(47, 211, 255, 0.24)) !important;
}
:root[data-theme="tech"] .home-recent-card__pin {
background: var(--home-tech-primary) !important;
box-shadow: 0 0 0 6px rgba(var(--home-tech-rgb), 0.14) !important;
}
@media (max-width: 768px) { @media (max-width: 768px) {
body::after {
inset: 10px;
border-radius: 18px;
opacity: 0.26;
}
#root::before {
bottom: 4%;
height: 120px;
}
#root::after {
height: 120px;
bottom: 5%;
}
.app-page { .app-page {
padding: 16px; padding: 16px;
} }
} }

View File

@ -1,4 +1,4 @@
import * as AntIcons from "@ant-design/icons"; import * as AntIcons from "@ant-design/icons";
import { import {
BellOutlined, BellOutlined,
ApartmentOutlined, ApartmentOutlined,
@ -74,6 +74,20 @@ export default function AppLayout() {
const { load: loadPermissions, can } = usePermission(); const { load: loadPermissions, can } = usePermission();
const { layoutMode } = useThemeStore(); const { layoutMode } = useThemeStore();
const currentUserDisplayName = useMemo(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr) as { displayName?: string; username?: string };
return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
}
} catch {
// Ignore invalid cached profile and continue with storage fallback.
}
return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
}, [t]);
const fetchInitialData = useCallback(async () => { const fetchInitialData = useCallback(async () => {
try { try {
const storedTenants = localStorage.getItem("availableTenants"); const storedTenants = localStorage.getItem("availableTenants");
@ -124,6 +138,8 @@ export default function AppLayout() {
const profile = await getCurrentUser(); const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile)); sessionStorage.setItem("userProfile", JSON.stringify(profile));
localStorage.setItem("displayName", profile.displayName || profile.username || "");
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
message.success(t("common.success")); message.success(t("common.success"));
window.location.reload(); window.location.reload();
@ -283,7 +299,7 @@ export default function AppLayout() {
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}> <Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
<span style={{ fontWeight: 500 }}>{localStorage.getItem("displayName") || t("layout.admin")}</span> <span style={{ fontWeight: 500 }}>{currentUserDisplayName}</span>
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>
@ -300,6 +316,16 @@ export default function AppLayout() {
borderBottom: isTop ? "none" : "1px solid var(--app-border-color)", borderBottom: isTop ? "none" : "1px solid var(--app-border-color)",
flexShrink: 0 flexShrink: 0
}} }}
>
<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" }} /> <img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
{(!collapsed || isTop) && ( {(!collapsed || isTop) && (
@ -317,6 +343,7 @@ export default function AppLayout() {
{platformConfig?.projectName || "UnisBase"} {platformConfig?.projectName || "UnisBase"}
</span> </span>
)} )}
</Link>
</div> </div>
); );
@ -442,3 +469,5 @@ export default function AppLayout() {
</Layout> </Layout>
); );
} }

View File

@ -86,8 +86,11 @@ export default function Login() {
try { try {
const profile = await getCurrentUser(); const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile)); sessionStorage.setItem("userProfile", JSON.stringify(profile));
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
localStorage.setItem("username", profile.username || values.username);
} catch { } catch {
sessionStorage.removeItem("userProfile"); sessionStorage.removeItem("userProfile");
localStorage.removeItem("displayName");
} }
message.success(t("common.success")); message.success(t("common.success"));

View File

@ -352,13 +352,13 @@ const HotWords: React.FC = () => {
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} /> <Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
</Form.Item> </Form.Item>
<Form.Item {/*<Form.Item*/}
name="pinyin" {/* name="pinyin"*/}
label="拼音" {/* label="拼音"*/}
tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果" {/* tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"*/}
> {/*>*/}
<Input placeholder="例如hui yi" /> {/* <Input placeholder="例如hui yi" />*/}
</Form.Item> {/*</Form.Item>*/}
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { import {
Card, Card,
@ -41,7 +41,8 @@ import {
getTranscripts, getTranscripts,
updateSpeakerInfo, updateSpeakerInfo,
reSummary, reSummary,
updateMeeting, updateMeetingBasic,
updateMeetingSummary,
MeetingVO, MeetingVO,
MeetingTranscriptVO, MeetingTranscriptVO,
getMeetingProgress, getMeetingProgress,
@ -306,9 +307,9 @@ const MeetingDetail: React.FC = () => {
const vals = await form.validateFields(); const vals = await form.validateFields();
setActionLoading(true); setActionLoading(true);
try { try {
await updateMeeting({ await updateMeetingBasic({
...vals, ...vals,
id: meeting?.id, meetingId: meeting?.id,
tags: vals.tags?.join(','), tags: vals.tags?.join(','),
}); });
message.success('会议信息已更新'); message.success('会议信息已更新');
@ -324,8 +325,8 @@ const MeetingDetail: React.FC = () => {
const handleSaveSummary = async () => { const handleSaveSummary = async () => {
setActionLoading(true); setActionLoading(true);
try { try {
await updateMeeting({ await updateMeetingSummary({
id: meeting?.id, meetingId: meeting?.id,
summaryContent: summaryDraft, summaryContent: summaryDraft,
}); });
message.success('总结内容已更新'); message.success('总结内容已更新');

View File

@ -162,7 +162,7 @@ const MeetingCreateForm: React.FC<{
size="small" size="small"
title={<Space><AudioOutlined /> </Space>} title={<Space><AudioOutlined /> </Space>}
bordered={false} bordered={false}
style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', background: '#f9fbff', flex: 1, display: 'flex', flexDirection: 'column' }} style={{ borderRadius: 12, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', flex: 1, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(16px)' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }} bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
> >
<Dragger <Dragger
@ -228,7 +228,7 @@ const MeetingCreateForm: React.FC<{
const isSelected = watchedPromptId === p.id; const isSelected = watchedPromptId === p.id;
return ( return (
<Col span={12} key={p.id}> <Col span={12} key={p.id}>
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`, backgroundColor: isSelected ? '#f0f7ff' : '#fff', cursor: 'pointer', textAlign: 'center', position: 'relative' }}> <div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? 'var(--app-primary-color)' : 'var(--app-border-color)'}`, background: isSelected ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'var(--app-bg-surface-strong)', cursor: 'pointer', textAlign: 'center', position: 'relative' }}>
<div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div> <div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div>
{isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>} {isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>}
</div> </div>
@ -263,7 +263,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
return ( return (
<List.Item style={{ marginBottom: 24 }}> <List.Item style={{ marginBottom: 24 }}>
<Card hoverable onClick={() => navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: 'none', height: '220px', position: 'relative', boxShadow: '0 6px 16px rgba(0,0,0,0.04)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}> <Card hoverable onClick={() => navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div> <div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}> <div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
@ -327,7 +327,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<div style={{ display: 'flex', gap: 4 }}>{item.tags?.split(',').slice(0, 2).map(t => ( <div style={{ display: 'flex', gap: 4 }}>{item.tags?.split(',').slice(0, 2).map(t => (
<Tag key={t} style={{ border: '1px solid #f0f0f0', backgroundColor: '#fff', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag> <Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
))}</div> ))}</div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} /> <RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
</div> </div>
@ -441,9 +441,9 @@ const Meetings: React.FC = () => {
}; };
return ( return (
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', backgroundColor: '#f4f7f9', padding: '24px', overflow: 'hidden' }}> <div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', background: 'var(--app-bg-page)', padding: '24px', overflow: 'hidden' }}>
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}> <div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }} bodyStyle={{ padding: '16px 28px' }}> <Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '16px 28px' }}>
<Row justify="space-between" align="middle"> <Row justify="space-between" align="middle">
<Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}></Title></Space></Col> <Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}></Title></Space></Col>
<Col> <Col>
@ -487,7 +487,7 @@ const Meetings: React.FC = () => {
onClose={() => setCreateDrawerVisible(false)} onClose={() => setCreateDrawerVisible(false)}
open={createDrawerVisible} open={createDrawerVisible}
destroyOnClose destroyOnClose
styles={{ body: { backgroundColor: '#f4f7f9', padding: '24px 32px' } }} styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }}
footer={ footer={
<div style={{ textAlign: 'right', padding: '12px 24px' }}> <div style={{ textAlign: 'right', padding: '12px 24px' }}>
<Space size={12}> <Space size={12}>
@ -538,10 +538,10 @@ const Meetings: React.FC = () => {
</Modal> </Modal>
<style>{` <style>{`
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; } .meeting-card:hover { transform: translateY(-4px); box-shadow: 0 18px 36px rgba(0,0,0,0.16) !important; }
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; } .status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } } @keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
.icon-btn { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.9); display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,0.1); transition: all 0.2s; color: #8c8c8c; } .icon-btn { width: 32px; height: 32px; border-radius: 50%; background: var(--app-bg-surface-strong); border: 1px solid var(--app-border-color); display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,0.1); transition: all 0.2s; color: var(--app-text-muted); }
.icon-btn:hover { transform: scale(1.1); } .icon-btn:hover { transform: scale(1.1); }
.icon-btn.edit:hover { color: #1890ff; background: #e6f7ff; } .icon-btn.edit:hover { color: #1890ff; background: #e6f7ff; }
.icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; } .icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; }

View File

@ -221,14 +221,14 @@ const PromptTemplates: React.FC = () => {
key={item.id} key={item.id}
hoverable hoverable
onClick={() => showDetail(item)} onClick={() => showDetail(item)}
style={{ width: 320, borderRadius: 12, border: '1px solid #f0f0f0', position: 'relative', overflow: 'hidden' }} style={{ width: 320, borderRadius: 12, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)', position: 'relative', overflow: 'hidden' }}
bodyStyle={{ padding: '24px' }} bodyStyle={{ padding: '24px' }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ <div style={{
width: 40, height: 40, borderRadius: 10, width: 40, height: 40, borderRadius: 10,
backgroundColor: isPlatformLevel ? '#fffbe6' : (isTenantLevel ? '#e6f7ff' : '#e6fffb'), background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
display: 'flex', justifyContent: 'center', alignItems: 'center' display: 'flex', justifyContent: 'center', alignItems: 'center'
}}> }}>
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} /> <StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
@ -255,7 +255,7 @@ const PromptTemplates: React.FC = () => {
{item.tags?.map(tag => { {item.tags?.map(tag => {
const dictItem = dictTags.find(dt => dt.itemValue === tag); const dictItem = dictTags.find(dt => dt.itemValue === tag);
return ( return (
<Tag key={tag} style={{ margin: 0, border: 'none', backgroundColor: '#f0f2f5', color: '#595959', borderRadius: 4, fontSize: 10 }}> <Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10 }}>
{dictItem ? dictItem.itemLabel : tag} {dictItem ? dictItem.itemLabel : tag}
</Tag> </Tag>
); );
@ -286,7 +286,7 @@ const PromptTemplates: React.FC = () => {
}; };
return ( return (
<div style={{ padding: '32px', backgroundColor: '#fff', minHeight: '100%', overflowY: 'auto' }}> <div style={{ padding: '32px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0 }}></Title> <Title level={3} style={{ margin: 0 }}></Title>
@ -295,7 +295,7 @@ const PromptTemplates: React.FC = () => {
</Button> </Button>
</div> </div>
<Card bordered={false} bodyStyle={{ padding: '20px 24px', backgroundColor: '#f9f9f9', borderRadius: 12, marginBottom: 32 }}> <Card bordered={false} style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '20px 24px' }}>
<Form form={searchForm} layout="inline" onFinish={fetchData}> <Form form={searchForm} layout="inline" onFinish={fetchData}>
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item> <Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
<Form.Item name="category" label="分类"> <Form.Item name="category" label="分类">
@ -418,7 +418,7 @@ const PromptTemplates: React.FC = () => {
/> />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 8, padding: '16px 24px' }}> <Col span={12} style={{ height: '100%', overflowY: 'auto', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', borderRadius: 8, padding: '16px 24px' }}>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div> <div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col> </Col>
</Row> </Row>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -155,7 +155,7 @@ const SpeakerReg: React.FC = () => {
}; };
return ( return (
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', backgroundColor: '#f8f9fa' }}> <div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
<div style={{ maxWidth: 800, margin: '0 auto' }}> <div style={{ maxWidth: 800, margin: '0 auto' }}>
<Title level={3}></Title> <Title level={3}></Title>
<Text type="secondary"> AI </Text> <Text type="secondary"> AI </Text>
@ -163,18 +163,18 @@ const SpeakerReg: React.FC = () => {
<Row gutter={24} style={{ marginTop: 24 }}> <Row gutter={24} style={{ marginTop: 24 }}>
{/* 左侧:采集与录音 */} {/* 左侧:采集与录音 */}
<Col span={15}> <Col span={15}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}> <Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Tabs defaultActiveKey="record"> <Tabs defaultActiveKey="record">
<Tabs.TabPane tab="在线录制" key="record"> <Tabs.TabPane tab="在线录制" key="record">
<div style={{ <div style={{
padding: '24px', padding: '24px',
backgroundColor: '#fafafa', background: 'var(--app-bg-surface-soft)',
borderRadius: 12, borderRadius: 12,
border: '1px solid #f0f0f0', border: '1px solid var(--app-border-color)',
marginBottom: 24, marginBottom: 24,
textAlign: 'center' textAlign: 'center'
}}> }}>
<Paragraph style={{ fontSize: 18, color: recording ? '#1890ff' : '#262626', fontWeight: 500, lineHeight: 1.8 }}> <Paragraph style={{ fontSize: 18, color: recording ? 'var(--app-primary-color)' : 'var(--app-text-main)', fontWeight: 500, lineHeight: 1.8 }}>
{REG_CONTENT} {REG_CONTENT}
</Paragraph> </Paragraph>
<Text type="secondary" size="small"></Text> <Text type="secondary" size="small"></Text>
@ -221,7 +221,7 @@ const SpeakerReg: React.FC = () => {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="本地上传" key="upload"> <Tabs.TabPane tab="本地上传" key="upload">
<div style={{ textAlign: 'center', padding: '40px 0', border: '1px dashed #d9d9d9', borderRadius: '8px', marginBottom: 24, backgroundColor: '#fafafa' }}> <div style={{ textAlign: 'center', padding: '40px 0', border: '1px dashed var(--app-border-color)', borderRadius: '8px', marginBottom: 24, background: 'var(--app-bg-surface-soft)' }}>
<Upload {...uploadProps} accept="audio/*"> <Upload {...uploadProps} accept="audio/*">
<Button icon={<UploadOutlined />} size="large"></Button> <Button icon={<UploadOutlined />} size="large"></Button>
</Upload> </Upload>
@ -233,7 +233,7 @@ const SpeakerReg: React.FC = () => {
</Tabs> </Tabs>
{audioUrl && ( {audioUrl && (
<div style={{ backgroundColor: '#f0f5ff', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid #adc6ff' }}> <div style={{ background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid color-mix(in srgb, var(--app-primary-color) 32%, var(--app-border-color))' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
<Text strong></Text> <Text strong></Text>
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}></Button> <Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}></Button>

View File

@ -48,7 +48,7 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
const isError = percent < 0; const isError = percent < 0;
return ( return (
<div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}> <div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} /> <LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
@ -137,12 +137,12 @@ export const Dashboard: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}> <div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Row gutter={24} style={{ marginBottom: 24 }}> <Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((s, idx) => ( {statCards.map((s, idx) => (
<Col span={6} key={idx}> <Col span={6} key={idx}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}> <Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Statistic <Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>} title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0} value={s.value || 0}
@ -162,7 +162,7 @@ export const Dashboard: React.FC = () => {
</div> </div>
} }
bordered={false} bordered={false}
style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
> >
<List <List
loading={dashboardLoading} loading={dashboardLoading}
@ -182,7 +182,7 @@ export const Dashboard: React.FC = () => {
</Space> </Space>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
{item.tags?.split(',').filter(Boolean).map((t) => ( {item.tags?.split(',').filter(Boolean).map((t) => (
<Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag> <Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
))} ))}
</div> </div>
</Space> </Space>

File diff suppressed because it is too large Load Diff

View File

@ -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>
);
}

View File

@ -2,6 +2,7 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import type { MenuRoute } from "@/types"; import type { MenuRoute } from "@/types";
const HomePage = lazy(() => import("@/pages/home"));
const Dashboard = lazy(() => import("@/pages/dashboard")); const Dashboard = lazy(() => import("@/pages/dashboard"));
const Profile = lazy(() => import("@/pages/profile")); const Profile = lazy(() => import("@/pages/profile"));
const Tenants = lazy(() => import("@/pages/organization/tenants")); const Tenants = lazy(() => import("@/pages/organization/tenants"));
@ -40,7 +41,7 @@ function LazyPage({ children }: { children: JSX.Element }) {
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>; return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
} }
export const menuRoutes: MenuRoute[] = [ export const menuRoutes: MenuRoute[] = [
{ path: "/", label: "总览", element: <Dashboard />, perm: "menu:dashboard" }, { path: "/", label: "首页", element: <HomePage />, perm: "menu:dashboard" },
{ path: "/profile", label: "个人中心", element: <Profile /> }, { path: "/profile", label: "个人中心", element: <Profile /> },
{ path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" }, { path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" },
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" }, { path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
@ -64,6 +65,7 @@ export const menuRoutes: MenuRoute[] = [
]; ];
export const extraRoutes = [ export const extraRoutes = [
{ path: "/dashboard-monitor", element: <Dashboard />, perm: "menu:dashboard" },
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" }, { path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
{ path: "/meeting-live-create", element: <RealtimeAsr />, perm: "menu:meeting" }, { path: "/meeting-live-create", element: <RealtimeAsr />, perm: "menu:meeting" },
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" } { path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" }

View File

@ -17,6 +17,26 @@ const DEFAULT_COLOR = '#1677ff';
const DEFAULT_MODE: ThemeMode = 'default'; const DEFAULT_MODE: ThemeMode = 'default';
const DEFAULT_LAYOUT: LayoutMode = 'side'; const DEFAULT_LAYOUT: LayoutMode = 'side';
function hexToRgbChannels(color: string): string {
const normalized = color.trim().replace('#', '');
if (/^[0-9a-fA-F]{3}$/.test(normalized)) {
const [r, g, b] = normalized.split('');
return `${parseInt(r + r, 16)}, ${parseInt(g + g, 16)}, ${parseInt(b + b, 16)}`;
}
if (/^[0-9a-fA-F]{6}$/.test(normalized)) {
return `${parseInt(normalized.slice(0, 2), 16)}, ${parseInt(normalized.slice(2, 4), 16)}, ${parseInt(normalized.slice(4, 6), 16)}`;
}
return '22, 119, 255';
}
function applyThemeColor(color: string) {
document.documentElement.style.setProperty('--app-primary-color', color);
document.documentElement.style.setProperty('--app-primary-rgb', hexToRgbChannels(color));
}
const getColorStorageKey = () => { const getColorStorageKey = () => {
const username = localStorage.getItem("username") || "default"; const username = localStorage.getItem("username") || "default";
return `unis_theme_color_${username}`; return `unis_theme_color_${username}`;
@ -40,7 +60,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
set({ colorPrimary: color }); set({ colorPrimary: color });
const key = getColorStorageKey(); const key = getColorStorageKey();
localStorage.setItem(key, color); localStorage.setItem(key, color);
document.documentElement.style.setProperty('--app-primary-color', color); applyThemeColor(color);
}, },
setThemeMode: (mode: ThemeMode) => { setThemeMode: (mode: ThemeMode) => {
set({ themeMode: mode }); set({ themeMode: mode });
@ -68,7 +88,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
const layout = storedLayout || DEFAULT_LAYOUT; const layout = storedLayout || DEFAULT_LAYOUT;
set({ colorPrimary: color, themeMode: mode, layoutMode: layout }); set({ colorPrimary: color, themeMode: mode, layoutMode: layout });
document.documentElement.style.setProperty('--app-primary-color', color); applyThemeColor(color);
document.documentElement.setAttribute('data-theme', mode); document.documentElement.setAttribute('data-theme', mode);
} }
})); }));