diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 05dba1b..a0a2d09 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -35,6 +35,14 @@ public final class RedisKeys { 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 SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_TYPE = "type"; diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 3696874..a3f51d4 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -8,6 +8,8 @@ import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.entity.biz.Meeting; import com.imeeting.security.LoginUser; 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.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -16,6 +18,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -25,13 +28,49 @@ import java.util.UUID; public class MeetingController { private final MeetingService meetingService; + private final StringRedisTemplate redisTemplate; 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.redisTemplate = redisTemplate; this.uploadPath = uploadPath; } + @GetMapping("/{id}/progress") + @PreAuthorize("isAuthenticated()") + public ApiResponse> 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 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") @PreAuthorize("isAuthenticated()") public ApiResponse upload(@RequestParam("file") MultipartFile file) throws IOException { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index ed3eb33..feb33b0 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -4,34 +4,37 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; 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.AiTask; +import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; -import com.imeeting.entity.biz.HotWord; -import com.imeeting.entity.SysUser; +import com.imeeting.mapper.SysUserMapper; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; -import com.imeeting.mapper.SysUserMapper; import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.HotWordService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.net.URI; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service @@ -45,6 +48,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final ObjectMapper objectMapper; private final SysUserMapper sysUserMapper; private final HotWordService hotWordService; + private final StringRedisTemplate redisTemplate; @Value("${app.server-base-url}") private String serverBaseUrl; @@ -56,23 +60,33 @@ public class AiTaskServiceImpl extends ServiceImpl impleme @Override @Async public void dispatchTasks(Long meetingId) { - log.info("Starting real AI processing for meeting ID: {}", meetingId); - Meeting meeting = meetingMapper.selectById(meetingId); - if (meeting == null) return; + // 尝试获取轮询锁,防止并发执行 + String lockKey = RedisKeys.meetingPollingLockKey(meetingId); + 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 { + log.info("Starting real AI processing for meeting ID: {}", meetingId); + Meeting meeting = meetingMapper.selectById(meetingId); + if (meeting == null) return; + // 1. 执行 ASR 识别 String asrText = processAsrTask(meeting); // 2. 执行 LLM 总结 processSummaryTask(meeting, asrText); + // 完成后清除进度 + redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId)); } catch (Exception e) { log.error("Meeting {} AI Task Flow failed", meetingId, e); - Meeting updateMeeting = new Meeting(); - updateMeeting.setId(meetingId); - updateMeeting.setStatus(4); // Overall Failed - meetingMapper.updateById(updateMeeting); + updateMeetingStatus(meetingId, 4); // Overall Failed + updateProgress(meetingId, -1, "分析失败: " + e.getMessage()); + } finally { + redisTemplate.delete(lockKey); } } @@ -95,123 +109,127 @@ public class AiTaskServiceImpl extends ServiceImpl impleme processSummaryTask(meeting, asrText); } catch (Exception e) { log.error("Re-summary failed for meeting {}", meetingId, e); - Meeting updateMeeting = new Meeting(); - updateMeeting.setId(meetingId); - updateMeeting.setStatus(4); // Failed - meetingMapper.updateById(updateMeeting); + updateMeetingStatus(meetingId, 4); } } private String processAsrTask(Meeting meeting) throws Exception { updateMeetingStatus(meeting.getId(), 1); // 识别中 + updateProgress(meeting.getId(), 5, "已提交识别请求..."); AiModel asrModel = aiModelService.getById(meeting.getAsrModelId()); if (asrModel == null) throw new RuntimeException("ASR Model config not found"); - // 构建请求参数 + // 构建请求参数并转码 Map req = new HashMap<>(); - String rawAudioUrl = meeting.getAudioUrl(); String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/")) .map(part -> { try { - return URLEncoder.encode(part, StandardCharsets.UTF_8.toString()).replace("+", "%20"); - } catch (Exception e) { - return part; - } + return URLEncoder.encode(part, StandardCharsets.UTF_8).replace("+", "%20"); + } catch (Exception e) { return part; } }) .collect(Collectors.joining("/")); - String fullAudioUrl = serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl; req.put("file_path", fullAudioUrl); - + req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1); + + // 处理热词权重 List> formattedHotwords = new ArrayList<>(); if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) { - List hotWordEntities = hotWordService.list(new LambdaQueryWrapper() + List entities = hotWordService.list(new LambdaQueryWrapper() .eq(HotWord::getTenantId, meeting.getTenantId()) .in(HotWord::getWord, meeting.getHotWords())); - - Map wordToWeightMap = hotWordEntities.stream() - .collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1)); - - for (String word : meeting.getHotWords()) { - Map 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); + Map weightMap = entities.stream().collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1)); + for (String w : meeting.getHotWords()) { + formattedHotwords.add(Map.of("hotword", w, "weight", weightMap.getOrDefault(w, 10) / 10.0)); } } req.put("hotwords", formattedHotwords); - req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1); AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req); - // 提交任务 + // 提交 String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition"; String respBody = postJson(submitUrl, req); JsonNode submitNode = objectMapper.readTree(respBody); - log.info(respBody); - if (submitNode.get("code").asInt() != 200) { + if (submitNode.path("code").asInt() != 200) { 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; - 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); String queryResp = get(queryUrl); 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)) { - resultNode = statusNode.get("data").get("result"); + resultNode = data.path("result"); updateAiTaskSuccess(taskRecord, statusNode); + updateProgress(meeting.getId(), 85, "语音转录完成,准备进行总结..."); break; } else if ("failed".equalsIgnoreCase(status)) { - updateAiTaskFail(taskRecord, "ASR Engine reported failure: " + queryResp); - throw new RuntimeException("ASR processing failed at engine"); + updateAiTaskFail(taskRecord, "ASR reported failure: " + queryResp); + 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(); - if (resultNode.has("segments")) { + JsonNode segments = resultNode.path("segments"); + if (segments.isArray()) { int order = 0; - for (JsonNode seg : resultNode.get("segments")) { + for (JsonNode seg : segments) { MeetingTranscript mt = new MeetingTranscript(); mt.setMeetingId(meeting.getId()); - String speakerIdStr = seg.has("speaker") ? seg.get("speaker").asText() : "spk_0"; - mt.setSpeakerId(speakerIdStr); - - String speakerName = speakerIdStr; - try { - Long userId = Long.valueOf(speakerIdStr); - SysUser user = sysUserMapper.selectById(userId); - if (user != null) { - speakerName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); - } - } catch (NumberFormatException e) { - // Not a user ID, keep the original speaker_id as name + // 解析 Speaker 对象 + JsonNode spkNode = seg.path("speaker"); + String spkId = spkNode.path("user_id").asText("spk_0"); + String spkName = spkNode.path("name").asText(spkId); + + // 用户名称转换逻辑 + if (spkId.matches("\\d+")) { + SysUser user = sysUserMapper.selectById(Long.parseLong(spkId)); + if (user != null) spkName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); } - 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")) { - mt.setStartTime(seg.get("timestamp").get(0).asInt()); - mt.setEndTime(seg.get("timestamp").get(1).asInt()); + mt.setStartTime(seg.path("timestamp").path(0).asInt()); + mt.setEndTime(seg.path("timestamp").path(1).asInt()); } mt.setSortOrder(order++); transcriptMapper.insert(mt); @@ -223,6 +241,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private void processSummaryTask(Meeting meeting, String asrText) throws Exception { updateMeetingStatus(meeting.getId(), 2); // 总结中 + updateProgress(meeting.getId(), 90, "正在进行 AI 智能总结..."); AiModel llmModel = aiModelService.getById(meeting.getSummaryModelId()); if (llmModel == null) return; @@ -230,14 +249,12 @@ public class AiTaskServiceImpl extends ServiceImpl impleme Map req = new HashMap<>(); req.put("model", llmModel.getModelCode()); req.put("temperature", llmModel.getTemperature()); - - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", meeting.getPromptContent())); - messages.add(Map.of("role", "user", "content", "请总结以下内容:\n" + asrText)); - req.put("messages", messages); + req.put("messages", List.of( + Map.of("role", "system", "content", meeting.getPromptContent()), + Map.of("role", "user", "content", "请总结以下内容:\n" + asrText) + )); AiTask taskRecord = createAiTask(meeting.getId(), "SUMMARY", req); - String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions"); HttpRequest request = HttpRequest.newBuilder() @@ -251,18 +268,30 @@ public class AiTaskServiceImpl extends ServiceImpl impleme JsonNode respNode = objectMapper.readTree(response.body()); 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.setStatus(3); // Finished meetingMapper.updateById(meeting); updateAiTaskSuccess(taskRecord, respNode); + updateProgress(meeting.getId(), 100, "分析已完成"); } else { 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 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 { HttpRequest request = HttpRequest.newBuilder() @@ -289,7 +318,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme AiTask task = new AiTask(); task.setMeetingId(meetingId); task.setTaskType(type); - task.setStatus(1); // Processing + task.setStatus(1); task.setRequestData(req); task.setStartedAt(LocalDateTime.now()); this.save(task); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index edce621..bbd9441 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -102,3 +102,15 @@ export const uploadAudio = (file: File) => { { headers: { "Content-Type": "multipart/form-data" } } ); }; + +export interface MeetingProgress { + percent: number; + message: string; + updateAt: number; +} + +export const getMeetingProgress = (id: number) => { + return http.get( + `/api/biz/meeting/${id}/progress` + ); +}; diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 949bdf9..220990d 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -88,6 +88,15 @@ export default function AppLayout() { .filter(p => (p.permType === 'menu' || p.permType === 'directory') && p.isVisible === 1 && p.status === 1) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); 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) { message.error(t('common.error')); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c2689a8..b1c93a6 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -9,10 +9,56 @@ import { import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; 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 MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { + const [progress, setProgress] = useState(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 ( +
+
+ + + {progress?.message || '准备分析中...'} + + {!isError && {percent}%} +
+ +
+ ); +}; + const Dashboard: React.FC = () => { const navigate = useNavigate(); const [stats, setStats] = useState(null); @@ -147,6 +193,9 @@ const Dashboard: React.FC = () => { + + {/* 进度实时展示 */} + )} diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 4e81754..4a856e5 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -14,6 +14,78 @@ import { SysUser } from '../../types'; const { Title, Text } = Typography; const { Option } = Select; +// 详情页进度显示组件 +const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => { + const [progress, setProgress] = useState(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 ( +
+
+ AI 智能分析中 + +
+ + {progress?.message || '正在准备计算资源...'} + + 分析过程大约需要 1-3 分钟,请耐心等待,您可以先去处理其他工作 +
+ + + + + 当前进度 + {isError ? 'ERROR' : `${percent}%`} + + + + + 预计剩余 + {isError ? '--' : (percent > 90 ? '即将完成' : '计算中')} + + + + + 任务状态 + {isError ? '已中断' : '正常'} + + + +
+
+ ); +}; + const SpeakerEditor: React.FC<{ meetingId: number; speakerId: string; @@ -231,36 +303,45 @@ const MeetingDetail: React.FC = () => {
- - - 语音转录} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} - extra={meeting.audioUrl && - - - AI 总结} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}> - {meeting.summaryContent ?
{meeting.summaryContent}
: -
{meeting.status === 2 ? 正在重新总结... : }
} -
- -
+ {(meeting.status === 1 || meeting.status === 2) ? ( + fetchData(meeting.id)} /> + ) : ( + + + 语音转录} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} + extra={meeting.audioUrl && + + + AI 总结} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}> + {meeting.summaryContent ?
{meeting.summaryContent}
: +
{meeting.status === 2 ? 正在重新总结... : }
} +
+ +
+ )}
+ {/* 修改基础信息弹窗 - 仅限 Owner */} {isOwner && ( diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index f16afab..9095325 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -6,11 +6,54 @@ import { TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined } from '@ant-design/icons'; 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 { Progress } from 'antd'; const { Text, Title } = Typography; +// 进度显示组件 +const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { + const [progress, setProgress] = useState(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 ( +
+
+ + {progress?.message || '处理中...'} + + {!isError && {percent}%} +
+ +
+ ); +}; + const Meetings: React.FC = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -112,73 +155,79 @@ const Meetings: React.FC = () => { style={{ borderRadius: 16, border: 'none', - height: '220px', + height: 'auto', + minHeight: '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%' }} + bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }} > - {/* 左侧状态装饰条 */} -
- -
+
+ {/* 左侧状态装饰条 */} +
- {/* 右上角醒目图标 */} -
e.stopPropagation()}> - - -
navigate(`/meetings/${item.id}`)}> - -
-
- deleteMeeting(item.id).then(fetchData)}> - -
- +
+ + {/* 右上角醒目图标 */} +
e.stopPropagation()}> + + +
navigate(`/meetings/${item.id}`)}> +
- -
-
- - {/* 内容排版 */} -
-
- - {item.status === 1 || item.status === 2 ? : null} - {config.text} - + deleteMeeting(item.id).then(fetchData)}> + +
+ +
+
+
+
-
- - {item.title} - -
- - -
- - {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} + {/* 内容排版 */} +
+
+ + {item.status === 1 || item.status === 2 ? : null} + {config.text} +
-
- - {item.participants || '无参与人员'} -
- -
- {/* 底部详情提示 */} -
-
- {item.tags?.split(',').slice(0, 2).map(t => ( - {t} - ))} +
+ + {item.title} + +
+ + +
+ + {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} +
+
+ + {item.participants || '无参与人员'} +
+
+
+ + {/* 底部详情提示 */} +
+
+ {item.tags?.split(',').slice(0, 2).map(t => ( + {t} + ))} +
+
-
+ + {/* 进度条显示 */} + );