feat: 添加会议进度显示和优化任务处理逻辑

- 在前端添加会议进度显示组件
- 优化后端任务调度逻辑,增加轮询锁防止并发执行
- 更新ASR和LLM任务处理流程,同步进度到Redis
- 重构会议详情页,展示AI分析进度和状态
- 修复和优化多处代码逻辑和样式问题
dev_na
chenhao 2026-03-04 17:19:41 +08:00
parent 80a4682757
commit 37025d3f02
8 changed files with 441 additions and 165 deletions

View File

@ -35,6 +35,14 @@ public final class RedisKeys {
return "sys:platform:config"; return "sys:platform:config";
} }
public static String meetingProgressKey(Long meetingId) {
return "biz:meeting:progress:" + meetingId;
}
public static String meetingPollingLockKey(Long meetingId) {
return "biz:meeting:polling:lock:" + meetingId;
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER"; public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type"; public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -8,6 +8,8 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser; import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.common.RedisKeys;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@ -16,6 +18,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -25,13 +28,49 @@ import java.util.UUID;
public class MeetingController { public class MeetingController {
private final MeetingService meetingService; private final MeetingService meetingService;
private final StringRedisTemplate redisTemplate;
private final String uploadPath; private final String uploadPath;
public MeetingController(MeetingService meetingService, @Value("${app.upload-path}") String uploadPath) { public MeetingController(MeetingService meetingService,
StringRedisTemplate redisTemplate,
@Value("${app.upload-path}") String uploadPath) {
this.meetingService = meetingService; this.meetingService = meetingService;
this.redisTemplate = redisTemplate;
this.uploadPath = uploadPath; this.uploadPath = uploadPath;
} }
@GetMapping("/{id}/progress")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> getProgress(@PathVariable Long id) {
String key = RedisKeys.meetingProgressKey(id);
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
try {
// 直接返回 Redis 中的进度 JSON
return ApiResponse.ok(new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Map.class));
} catch (Exception e) {
return ApiResponse.error("解析进度异常");
}
}
// 如果 Redis 没数据,根据数据库状态返回
Meeting m = meetingService.getById(id);
Map<String, Object> fallback = new HashMap<>();
if (m != null) {
if (m.getStatus() == 3) {
fallback.put("percent", 100);
fallback.put("message", "分析已完成");
} else if (m.getStatus() == 4) {
fallback.put("percent", -1);
fallback.put("message", "分析失败");
} else {
fallback.put("percent", 0);
fallback.put("message", "等待处理...");
}
}
return ApiResponse.ok(fallback);
}
@PostMapping("/upload") @PostMapping("/upload")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException { public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {

View File

@ -4,34 +4,37 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysUser;
import com.imeeting.entity.biz.AiModel; import com.imeeting.entity.biz.AiModel;
import com.imeeting.entity.biz.AiTask; 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.MeetingTranscript;
import com.imeeting.entity.biz.HotWord; import com.imeeting.mapper.SysUserMapper;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.biz.AiTaskMapper; 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.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.HotWordService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -45,6 +48,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
private final HotWordService hotWordService; private final HotWordService hotWordService;
private final StringRedisTemplate redisTemplate;
@Value("${app.server-base-url}") @Value("${app.server-base-url}")
private String serverBaseUrl; private String serverBaseUrl;
@ -56,23 +60,33 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
@Override @Override
@Async @Async
public void dispatchTasks(Long meetingId) { public void dispatchTasks(Long meetingId) {
log.info("Starting real AI processing for meeting ID: {}", meetingId); // 尝试获取轮询锁,防止并发执行
Meeting meeting = meetingMapper.selectById(meetingId); String lockKey = RedisKeys.meetingPollingLockKey(meetingId);
if (meeting == null) return; Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(acquired)) {
log.warn("Meeting {} task is already being processed by another thread", meetingId);
return;
}
try { try {
log.info("Starting real AI processing for meeting ID: {}", meetingId);
Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) return;
// 1. 执行 ASR 识别 // 1. 执行 ASR 识别
String asrText = processAsrTask(meeting); String asrText = processAsrTask(meeting);
// 2. 执行 LLM 总结 // 2. 执行 LLM 总结
processSummaryTask(meeting, asrText); processSummaryTask(meeting, asrText);
// 完成后清除进度
redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId));
} catch (Exception e) { } catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e); log.error("Meeting {} AI Task Flow failed", meetingId, e);
Meeting updateMeeting = new Meeting(); updateMeetingStatus(meetingId, 4); // Overall Failed
updateMeeting.setId(meetingId); updateProgress(meetingId, -1, "分析失败: " + e.getMessage());
updateMeeting.setStatus(4); // Overall Failed } finally {
meetingMapper.updateById(updateMeeting); redisTemplate.delete(lockKey);
} }
} }
@ -95,123 +109,127 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
processSummaryTask(meeting, asrText); processSummaryTask(meeting, asrText);
} catch (Exception e) { } catch (Exception e) {
log.error("Re-summary failed for meeting {}", meetingId, e); log.error("Re-summary failed for meeting {}", meetingId, e);
Meeting updateMeeting = new Meeting(); updateMeetingStatus(meetingId, 4);
updateMeeting.setId(meetingId);
updateMeeting.setStatus(4); // Failed
meetingMapper.updateById(updateMeeting);
} }
} }
private String processAsrTask(Meeting meeting) throws Exception { private String processAsrTask(Meeting meeting) throws Exception {
updateMeetingStatus(meeting.getId(), 1); // 识别中 updateMeetingStatus(meeting.getId(), 1); // 识别中
updateProgress(meeting.getId(), 5, "已提交识别请求...");
AiModel asrModel = aiModelService.getById(meeting.getAsrModelId()); AiModel asrModel = aiModelService.getById(meeting.getAsrModelId());
if (asrModel == null) throw new RuntimeException("ASR Model config not found"); if (asrModel == null) throw new RuntimeException("ASR Model config not found");
// 构建请求参数 // 构建请求参数并转码
Map<String, Object> req = new HashMap<>(); Map<String, Object> req = new HashMap<>();
String rawAudioUrl = meeting.getAudioUrl(); String rawAudioUrl = meeting.getAudioUrl();
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/")) String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
.map(part -> { .map(part -> {
try { try {
return URLEncoder.encode(part, StandardCharsets.UTF_8.toString()).replace("+", "%20"); return URLEncoder.encode(part, StandardCharsets.UTF_8).replace("+", "%20");
} catch (Exception e) { } catch (Exception e) { return part; }
return part;
}
}) })
.collect(Collectors.joining("/")); .collect(Collectors.joining("/"));
String fullAudioUrl = serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl; String fullAudioUrl = serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl;
req.put("file_path", fullAudioUrl); req.put("file_path", fullAudioUrl);
req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1);
// 处理热词权重
List<Map<String, Object>> formattedHotwords = new ArrayList<>(); List<Map<String, Object>> formattedHotwords = new ArrayList<>();
if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) { if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) {
List<HotWord> hotWordEntities = hotWordService.list(new LambdaQueryWrapper<HotWord>() List<HotWord> entities = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, meeting.getTenantId()) .eq(HotWord::getTenantId, meeting.getTenantId())
.in(HotWord::getWord, meeting.getHotWords())); .in(HotWord::getWord, meeting.getHotWords()));
Map<String, Integer> weightMap = entities.stream().collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1));
Map<String, Integer> wordToWeightMap = hotWordEntities.stream() for (String w : meeting.getHotWords()) {
.collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1)); formattedHotwords.add(Map.of("hotword", w, "weight", weightMap.getOrDefault(w, 10) / 10.0));
for (String word : meeting.getHotWords()) {
Map<String, Object> hwMap = new HashMap<>();
hwMap.put("hotword", word);
// Default weight is 1.0 (assuming 10 corresponds to 1.0, 20 corresponds to 2.0 etc., or keep original logic)
// Let's map 1-100 to 0.1-10.0 or just keep integer? The prompt shows weight: 2.0
// Assuming weight in DB is 10 for normal, maybe weight / 10.0
double calculatedWeight = wordToWeightMap.getOrDefault(word, 10) / 10.0;
hwMap.put("weight", calculatedWeight);
formattedHotwords.add(hwMap);
} }
} }
req.put("hotwords", formattedHotwords); req.put("hotwords", formattedHotwords);
req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1);
AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req); AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req);
// 提交任务 // 提交
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition"; String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
String respBody = postJson(submitUrl, req); String respBody = postJson(submitUrl, req);
JsonNode submitNode = objectMapper.readTree(respBody); JsonNode submitNode = objectMapper.readTree(respBody);
log.info(respBody); if (submitNode.path("code").asInt() != 200) {
if (submitNode.get("code").asInt() != 200) {
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody); updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
throw new RuntimeException("ASR submission failed"); throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
} }
String taskId = submitNode.get("data").get("task_id").asText(); String taskId = submitNode.path("data").path("task_id").asText();
// 轮询状态
String queryUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/" + taskId : asrModel.getBaseUrl() + "/api/tasks/" + taskId; String queryUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/" + taskId : asrModel.getBaseUrl() + "/api/tasks/" + taskId;
JsonNode resultNode = null;
for (int i = 0; i < 300; i++) { // Max 10 minutes // 轮询带防护
JsonNode resultNode = null;
int lastPercent = -1;
int unchangedCount = 0;
for (int i = 0; i < 600; i++) { // Max 20 minutes
Thread.sleep(2000); Thread.sleep(2000);
String queryResp = get(queryUrl); String queryResp = get(queryUrl);
JsonNode statusNode = objectMapper.readTree(queryResp); JsonNode statusNode = objectMapper.readTree(queryResp);
String status = statusNode.get("data").get("status").asText(); JsonNode data = statusNode.path("data");
String status = data.path("status").asText();
if ("completed".equalsIgnoreCase(status)) { if ("completed".equalsIgnoreCase(status)) {
resultNode = statusNode.get("data").get("result"); resultNode = data.path("result");
updateAiTaskSuccess(taskRecord, statusNode); updateAiTaskSuccess(taskRecord, statusNode);
updateProgress(meeting.getId(), 85, "语音转录完成,准备进行总结...");
break; break;
} else if ("failed".equalsIgnoreCase(status)) { } else if ("failed".equalsIgnoreCase(status)) {
updateAiTaskFail(taskRecord, "ASR Engine reported failure: " + queryResp); updateAiTaskFail(taskRecord, "ASR reported failure: " + queryResp);
throw new RuntimeException("ASR processing failed at engine"); throw new RuntimeException("ASR处理失败: " + data.path("message").asText());
} else {
// 处理中:同步进度到 Redis
int currentPercent = data.path("percentage").asInt();
String message = data.path("message").asText();
// 缩放到 0-85% 范围
int scaledPercent = (int)(currentPercent * 0.85);
updateProgress(meeting.getId(), Math.max(5, scaledPercent), message);
// 防死循环逻辑:如果进度长时间不动且不是 0
if (currentPercent > 0 && currentPercent == lastPercent) {
unchangedCount++;
if (unchangedCount > 40) throw new RuntimeException("ASR处理停滞自动超时");
} else {
unchangedCount = 0;
}
lastPercent = currentPercent;
} }
} }
if (resultNode == null) throw new RuntimeException("ASR polling timeout"); if (resultNode == null) throw new RuntimeException("ASR轮询超时");
// 解析并入库转录明细 // 解析并入库 (适配新 Speaker 格式)
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (resultNode.has("segments")) { JsonNode segments = resultNode.path("segments");
if (segments.isArray()) {
int order = 0; int order = 0;
for (JsonNode seg : resultNode.get("segments")) { for (JsonNode seg : segments) {
MeetingTranscript mt = new MeetingTranscript(); MeetingTranscript mt = new MeetingTranscript();
mt.setMeetingId(meeting.getId()); mt.setMeetingId(meeting.getId());
String speakerIdStr = seg.has("speaker") ? seg.get("speaker").asText() : "spk_0"; // 解析 Speaker 对象
mt.setSpeakerId(speakerIdStr); JsonNode spkNode = seg.path("speaker");
String spkId = spkNode.path("user_id").asText("spk_0");
String speakerName = speakerIdStr; String spkName = spkNode.path("name").asText(spkId);
try {
Long userId = Long.valueOf(speakerIdStr); // 用户名称转换逻辑
SysUser user = sysUserMapper.selectById(userId); if (spkId.matches("\\d+")) {
if (user != null) { SysUser user = sysUserMapper.selectById(Long.parseLong(spkId));
speakerName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); if (user != null) spkName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
}
} catch (NumberFormatException e) {
// Not a user ID, keep the original speaker_id as name
} }
mt.setSpeakerName(speakerName);
mt.setContent(seg.get("text").asText()); mt.setSpeakerId(spkId);
mt.setSpeakerName(spkName);
mt.setContent(seg.path("text").asText());
if (seg.has("timestamp")) { if (seg.has("timestamp")) {
mt.setStartTime(seg.get("timestamp").get(0).asInt()); mt.setStartTime(seg.path("timestamp").path(0).asInt());
mt.setEndTime(seg.get("timestamp").get(1).asInt()); mt.setEndTime(seg.path("timestamp").path(1).asInt());
} }
mt.setSortOrder(order++); mt.setSortOrder(order++);
transcriptMapper.insert(mt); transcriptMapper.insert(mt);
@ -223,6 +241,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private void processSummaryTask(Meeting meeting, String asrText) throws Exception { private void processSummaryTask(Meeting meeting, String asrText) throws Exception {
updateMeetingStatus(meeting.getId(), 2); // 总结中 updateMeetingStatus(meeting.getId(), 2); // 总结中
updateProgress(meeting.getId(), 90, "正在进行 AI 智能总结...");
AiModel llmModel = aiModelService.getById(meeting.getSummaryModelId()); AiModel llmModel = aiModelService.getById(meeting.getSummaryModelId());
if (llmModel == null) return; if (llmModel == null) return;
@ -230,14 +249,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
Map<String, Object> req = new HashMap<>(); Map<String, Object> req = new HashMap<>();
req.put("model", llmModel.getModelCode()); req.put("model", llmModel.getModelCode());
req.put("temperature", llmModel.getTemperature()); req.put("temperature", llmModel.getTemperature());
req.put("messages", List.of(
List<Map<String, String>> messages = new ArrayList<>(); Map.of("role", "system", "content", meeting.getPromptContent()),
messages.add(Map.of("role", "system", "content", meeting.getPromptContent())); Map.of("role", "user", "content", "请总结以下内容:\n" + asrText)
messages.add(Map.of("role", "user", "content", "请总结以下内容:\n" + asrText)); ));
req.put("messages", messages);
AiTask taskRecord = createAiTask(meeting.getId(), "SUMMARY", req); AiTask taskRecord = createAiTask(meeting.getId(), "SUMMARY", req);
String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions"); String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions");
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@ -251,18 +268,30 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
JsonNode respNode = objectMapper.readTree(response.body()); JsonNode respNode = objectMapper.readTree(response.body());
if (response.statusCode() == 200 && respNode.has("choices")) { if (response.statusCode() == 200 && respNode.has("choices")) {
String content = respNode.get("choices").get(0).get("message").get("content").asText(); String content = respNode.path("choices").path(0).path("message").path("content").asText();
meeting.setSummaryContent(content); meeting.setSummaryContent(content);
meeting.setStatus(3); // Finished meeting.setStatus(3); // Finished
meetingMapper.updateById(meeting); meetingMapper.updateById(meeting);
updateAiTaskSuccess(taskRecord, respNode); updateAiTaskSuccess(taskRecord, respNode);
updateProgress(meeting.getId(), 100, "分析已完成");
} else { } else {
updateAiTaskFail(taskRecord, "LLM failed: " + response.body()); updateAiTaskFail(taskRecord, "LLM failed: " + response.body());
throw new RuntimeException("LLM processing failed"); throw new RuntimeException("AI总结生成失败");
} }
} }
// --- Helpers --- private void updateProgress(Long meetingId, int percent, String msg) {
try {
Map<String, Object> progress = new HashMap<>();
progress.put("percent", percent);
progress.put("message", msg);
progress.put("updateAt", System.currentTimeMillis());
redisTemplate.opsForValue().set(RedisKeys.meetingProgressKey(meetingId),
objectMapper.writeValueAsString(progress), 1, TimeUnit.HOURS);
} catch (Exception e) {
log.error("Update progress to redis failed", e);
}
}
private String postJson(String url, Object body) throws Exception { private String postJson(String url, Object body) throws Exception {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@ -289,7 +318,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
AiTask task = new AiTask(); AiTask task = new AiTask();
task.setMeetingId(meetingId); task.setMeetingId(meetingId);
task.setTaskType(type); task.setTaskType(type);
task.setStatus(1); // Processing task.setStatus(1);
task.setRequestData(req); task.setRequestData(req);
task.setStartedAt(LocalDateTime.now()); task.setStartedAt(LocalDateTime.now());
this.save(task); this.save(task);

View File

@ -102,3 +102,15 @@ export const uploadAudio = (file: File) => {
{ headers: { "Content-Type": "multipart/form-data" } } { headers: { "Content-Type": "multipart/form-data" } }
); );
}; };
export interface MeetingProgress {
percent: number;
message: string;
updateAt: number;
}
export const getMeetingProgress = (id: number) => {
return http.get<any, { code: string; data: MeetingProgress; msg: string }>(
`/api/biz/meeting/${id}/progress`
);
};

View File

@ -88,6 +88,15 @@ export default function AppLayout() {
.filter(p => (p.permType === 'menu' || p.permType === 'directory') && p.isVisible === 1 && p.status === 1) .filter(p => (p.permType === 'menu' || p.permType === 'directory') && p.isVisible === 1 && p.status === 1)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
setMenus(filtered); setMenus(filtered);
// 如果当前是根路径,自动跳转到第一个有权限的菜单
if (location.pathname === '/' && filtered.length > 0) {
// 查找第一个类型为 'menu' 且有路径的项
const firstMenu = filtered.find(m => m.permType === 'menu' && m.path);
if (firstMenu && firstMenu.path !== '/') {
navigate(firstMenu.path, { replace: true });
}
}
} catch (e) { } catch (e) {
message.error(t('common.error')); message.error(t('common.error'));
} }

View File

@ -9,10 +9,56 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard'; import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
import { MeetingVO } from '../api/business/meeting'; import { MeetingVO, getMeetingProgress, MeetingProgress } from '../api/business/meeting';
const { Title, Text } = Typography; const { Title, Text } = Typography;
// 新增进度显示子组件
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) return;
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) {
setProgress(res.data.data);
}
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
if (meeting.status !== 1 && meeting.status !== 2) return null;
const percent = progress?.percent || 0;
const isError = percent < 0;
return (
<div style={{ marginTop: 12, padding: '12px 16px', backgroundColor: '#f8f9ff', borderRadius: 8, border: '1px solid #e6f4ff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text size="small" type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'}
</Text>
{!isError && <Text size="small" strong style={{ color: '#1890ff' }}>{percent}%</Text>}
</div>
<Progress
percent={isError ? 100 : percent}
size="small"
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
showInfo={false}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
/>
</div>
);
};
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
@ -147,6 +193,9 @@ const Dashboard: React.FC = () => {
</Button> </Button>
</Col> </Col>
</Row> </Row>
{/* 进度实时展示 */}
<MeetingProgressDisplay meeting={item} />
</div> </div>
</List.Item> </List.Item>
)} )}

View File

@ -14,6 +14,78 @@ import { SysUser } from '../../types';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
// 详情页进度显示组件
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meetingId);
if (res.data && res.data.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100) {
onComplete();
}
}
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meetingId]);
const percent = progress?.percent || 0;
const isError = percent < 0;
return (
<div style={{
height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center',
background: '#fff', borderRadius: 16, padding: 40
}}>
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 24 }}>AI </Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
width={180}
strokeWidth={8}
/>
<div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#1890ff', display: 'block', marginBottom: 8 }}>
{progress?.message || '正在准备计算资源...'}
</Text>
<Text type="secondary"> 1-3 </Text>
</div>
<Divider style={{ margin: '32px 0' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? '--' : (percent > 90 ? '即将完成' : '计算中')}</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary" size="small"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>{isError ? '已中断' : '正常'}</Title>
</Space>
</Col>
</Row>
</div>
</div>
);
};
const SpeakerEditor: React.FC<{ const SpeakerEditor: React.FC<{
meetingId: number; meetingId: number;
speakerId: string; speakerId: string;
@ -231,36 +303,45 @@ const MeetingDetail: React.FC = () => {
</Card> </Card>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<Row gutter={24} style={{ height: '100%' }}> {(meeting.status === 1 || meeting.status === 2) ? (
<Col span={12} style={{ height: '100%' }}> <MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
<Card title={<span><AudioOutlined /> </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} ) : (
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}> <Row gutter={24} style={{ height: '100%' }}>
<List dataSource={transcripts} renderItem={(item) => ( <Col span={12} style={{ height: '100%' }}>
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}> <Card title={<span><AudioOutlined /> </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }}
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />} extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}>
title={<Space> <List dataSource={transcripts} renderItem={(item) => (
{isOwner ? ( <List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}>
<Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click"> <List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span> title={<Space>
</Popover> {isOwner ? (
) : ( <Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click">
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text> <span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span>
)} </Popover>
{item.speakerLabel && <Tag color="blue">{speakerLabels.find(l => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}</Tag>} ) : (
<Text type="secondary" size="small" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text> <Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} /> )}
</List.Item> {item.speakerLabel && <Tag color="blue">{speakerLabels.find(l => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}</Tag>}
)} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} /> <Text type="secondary" size="small" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text>
</Card> </Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} />
</Col> </List.Item>
<Col span={12} style={{ height: '100%' }}> )} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
<Card title={<span><RobotOutlined /> AI </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}> </Card>
{meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> : </Col>
<div style={{ textAlign: 'center', marginTop: '100px' }}>{meeting.status === 2 ? <Space direction="vertical"><LoadingOutlined style={{ fontSize: 24 }} spin /><Text type="secondary">...</Text></Space> : <Empty description="暂无总结" />}</div>} <Col span={12} style={{ height: '100%' }}>
</Card> <Card title={<span><RobotOutlined /> AI </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}>
</Col> {meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> :
</Row> <div style={{ textAlign: 'center', marginTop: '100px' }}>{meeting.status === 2 ? <Space direction="vertical"><LoadingOutlined style={{ fontSize: 24 }} spin /><Text type="secondary">...</Text></Space> : <Empty description="暂无总结" />}</div>}
</Card>
</Col>
</Row>
)}
</div> </div>
<style>{`
.markdown-body { font-size: 14px; line-height: 1.8; color: #333; }
.markdown-body p { margin-bottom: 16px; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; }
`}</style>
{/* 修改基础信息弹窗 - 仅限 Owner */} {/* 修改基础信息弹窗 - 仅限 Owner */}
{isOwner && ( {isOwner && (

View File

@ -6,11 +6,54 @@ import {
TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getMeetingPage, deleteMeeting, MeetingVO } from '../../api/business/meeting'; import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress } from '../../api/business/meeting';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Progress } from 'antd';
const { Text, Title } = Typography; const { Text, Title } = Typography;
// 进度显示组件
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) return;
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) setProgress(res.data.data);
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
if (meeting.status !== 1 && meeting.status !== 2) return null;
const percent = progress?.percent || 0;
const isError = percent < 0;
return (
<div style={{ padding: '0 24px 12px 24px', marginTop: -8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
{progress?.message || '处理中...'}
</Text>
{!isError && <Text strong style={{ color: '#1890ff', fontSize: 11 }}>{percent}%</Text>}
</div>
<Progress
percent={isError ? 100 : percent}
size="small"
status={isError ? 'exception' : 'active'}
showInfo={false}
strokeColor={isError ? '#ff4d4f' : '#1890ff'}
style={{ margin: 0 }}
/>
</div>
);
};
const Meetings: React.FC = () => { const Meetings: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -112,73 +155,79 @@ const Meetings: React.FC = () => {
style={{ style={{
borderRadius: 16, borderRadius: 16,
border: 'none', border: 'none',
height: '220px', height: 'auto',
minHeight: '220px',
position: 'relative', position: 'relative',
boxShadow: '0 6px 16px rgba(0,0,0,0.04)', boxShadow: '0 6px 16px rgba(0,0,0,0.04)',
transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)'
}} }}
bodyStyle={{ padding: 0, display: 'flex', height: '100%' }} bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
> >
{/* 左侧状态装饰条 */} <div style={{ display: 'flex', flex: 1 }}>
<div style={{ width: 6, height: '100%', backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div> {/* 左侧状态装饰条 */}
<div 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()}>
<Space size={8}> {/* 右上角醒目图标 */}
<Tooltip title="编辑"> <div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
<div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}> <Space size={8}>
<EditOutlined /> <Tooltip title="编辑">
</div> <div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}>
</Tooltip> <EditOutlined />
<Popconfirm title="确定删除?" onConfirm={() => deleteMeeting(item.id).then(fetchData)}>
<Tooltip title="删除">
<div className="icon-btn delete">
<DeleteOutlined />
</div> </div>
</Tooltip> </Tooltip>
</Popconfirm> <Popconfirm title="确定删除?" onConfirm={() => deleteMeeting(item.id).then(fetchData)}>
</Space> <Tooltip title="删除">
</div> <div className="icon-btn delete">
<DeleteOutlined />
{/* 内容排版 */} </div>
<div style={{ flex: 1 }}> </Tooltip>
<div style={{ marginBottom: 12 }}> </Popconfirm>
<Tag color={config.bgColor} style={{ color: config.color, border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11 }}> </Space>
{item.status === 1 || item.status === 2 ? <LoadingOutlined spin style={{ marginRight: 4 }} /> : null}
{config.text}
</Tag>
</div> </div>
<div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}> {/* 内容排版 */}
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}> <div style={{ flex: 1 }}>
{item.title} <div style={{ marginBottom: 12 }}>
</Text> <Tag color={config.bgColor} style={{ color: config.color, border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11 }}>
</div> {item.status === 1 || item.status === 2 ? <LoadingOutlined spin style={{ marginRight: 4 }} /> : null}
{config.text}
<Space direction="vertical" size={10} style={{ width: '100%' }}> </Tag>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<CalendarOutlined style={{ marginRight: 10 }} />
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
</div> </div>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<TeamOutlined style={{ marginRight: 10 }} />
<Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text>
</div>
</Space>
</div>
{/* 底部详情提示 */} <div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}> <Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>
<div style={{ display: 'flex', gap: 4 }}> {item.title}
{item.tags?.split(',').slice(0, 2).map(t => ( </Text>
<Tag key={t} style={{ border: '1px solid #f0f0f0', backgroundColor: '#fff', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag> </div>
))}
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<CalendarOutlined style={{ marginRight: 10 }} />
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
</div>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<TeamOutlined style={{ marginRight: 10 }} />
<Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text>
</div>
</Space>
</div>
{/* 底部详情提示 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<div style={{ display: 'flex', gap: 4 }}>
{item.tags?.split(',').slice(0, 2).map(t => (
<Tag key={t} style={{ border: '1px solid #f0f0f0', backgroundColor: '#fff', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
))}
</div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
</div> </div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
</div> </div>
</div> </div>
{/* 进度条显示 */}
<MeetingProgressDisplay meeting={item} />
</Card> </Card>
</List.Item> </List.Item>
); );