diff --git a/backend/src/test/java/com/imeeting/biz/SummaryTest.java b/backend/src/test/java/com/imeeting/biz/SummaryTest.java new file mode 100644 index 0000000..04ccb0a --- /dev/null +++ b/backend/src/test/java/com/imeeting/biz/SummaryTest.java @@ -0,0 +1,152 @@ +package com.imeeting.biz; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.entity.biz.AiModel; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.biz.AiModelService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 总结模块分步分析测试类 - 真实数据版 + */ +@SpringBootTest +public class SummaryTest { + + @Autowired + private MeetingMapper meetingMapper; + + @Autowired + private MeetingTranscriptMapper transcriptMapper; + + @Autowired + private AiModelService aiModelService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void testManualSummary() throws Exception { + // --- 步骤 1: 准备测试数据 --- + // 请替换为您数据库中真实的 meetingId + Long testMeetingId = 3L; + + Meeting meeting = meetingMapper.selectById(testMeetingId); + if (meeting == null) { + System.out.println("❌ 错误:未找到 ID 为 " + testMeetingId + " 的会议记录"); + return; + } + + // 获取真实的 ASR 转录数据 + List transcripts = transcriptMapper.selectList( + new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, testMeetingId) + .orderByAsc(MeetingTranscript::getStartTime) + ); + + if (transcripts.isEmpty()) { + System.out.println("⚠️ 警告:该会议暂无转录明细数据 (MeetingTranscript)"); + // 如果没明细,您可以选择是否继续,或者手动造一点 + // return; + } + + String realAsrText = transcripts.stream() + .map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent()) + .collect(Collectors.joining("\n")); + + System.out.println("\n--- [DEBUG] 提取到的真实转录文本 ---"); + System.out.println(realAsrText); + + AiModel llmModel = aiModelService.getById(meeting.getSummaryModelId()); + if (llmModel == null) { + System.out.println("❌ 错误:该会议未绑定总结模型配置"); + return; + } + + System.out.println("\n✅ 基础数据加载成功"); + System.out.println(" 模型名称: " + llmModel.getModelName()); + System.out.println(" 提示词模板快照: " + (meeting.getPromptContent() != null && meeting.getPromptContent().length() > 50 + ? meeting.getPromptContent().substring(0, 50) + "..." + : meeting.getPromptContent())); + + // --- 步骤 2: 构造请求 Payload --- + Map req = new HashMap<>(); + req.put("model", llmModel.getModelCode()); + req.put("temperature", llmModel.getTemperature()); + + List> messages = new ArrayList<>(); + // 系统角色注入 Prompt + messages.add(Map.of("role", "system", "content", meeting.getPromptContent() != null ? meeting.getPromptContent() : "请总结以下会议内容")); + // 用户角色注入 真实的 ASR 文本 + messages.add(Map.of("role", "user", "content", "以下是会议转录全文:\n" + realAsrText)); + req.put("messages", messages); + + String jsonPayload = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(req); + System.out.println("\n--- [DEBUG] 发送给 AI 的请求 JSON ---"); + System.out.println(jsonPayload); + + // --- 步骤 3: 发起网络请求 --- + String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions"); + System.out.println("\n--- [DEBUG] 目标 URL: " + url); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + llmModel.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + System.out.println("⏳ 正在请求第三方 AI 接口..."); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + System.out.println("\n--- [DEBUG] 接口返回状态码: " + response.statusCode()); + System.out.println("--- [DEBUG] 接口返回 Raw Body ---"); + System.out.println(response.body()); + + // --- 步骤 4: 解析结果 --- + if (response.statusCode() == 200) { + JsonNode respNode = objectMapper.readTree(response.body()); + if (respNode.has("choices")) { + String finalContent = respNode.get("choices").get(0).get("message").get("content").asText(); + System.out.println("\n✨ 总结生成成功!结果如下:"); + System.out.println("------------------------------------"); + System.out.println(finalContent); + System.out.println("------------------------------------"); + + // 可选:将结果更新回数据库以便前端查看 + // meeting.setSummaryContent(finalContent); + // meetingMapper.updateById(meeting); + } else { + System.out.println("❌ 错误:返回结果中不包含 'choices' 字段,请检查厂商 API 适配。"); + } + } else { + System.out.println("❌ 接口请求失败,请检查 BaseUrl 和 ApiKey 是否正确。"); + } + } catch (Exception e) { + System.out.println("❌ 网络异常:" + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1512200..ad858dc 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -246,9 +246,9 @@ export default function Login() {
- - {t('login.demoAccount')}:admin / {t('login.password')}:123456 - + {/**/} + {/* {t('login.demoAccount')}:admin / {t('login.password')}:123456*/} + {/**/}
diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 4a856e5..468dc15 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Card, Row, Col, Typography, Tag, Space, Divider, Button, Skeleton, Empty, List, Avatar, Breadcrumb, Popover, Input, Select, message, Drawer, Form, Modal } from 'antd'; +import { Card, Row, Col, Typography, Tag, Space, Divider, Button, Skeleton, Empty, List, Avatar, Breadcrumb, Popover, Input, Select, message, Drawer, Form, Modal, Progress } from 'antd'; import { LeftOutlined, UserOutlined, ClockCircleOutlined, AudioOutlined, RobotOutlined, LoadingOutlined, EditOutlined, SyncOutlined, SettingOutlined } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import dayjs from 'dayjs'; -import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO } from '../../api/business/meeting'; +import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO, getMeetingProgress, MeetingProgress } from '../../api/business/meeting'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { useDict } from '../../hooks/useDict'; diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 9095325..c69a5d1 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -3,17 +3,17 @@ import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, import { PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined, LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined, - TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined + TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined, + SyncOutlined, InfoCircleOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; 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 IntegratedStatusTag: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { const [progress, setProgress] = useState(null); useEffect(() => { @@ -29,31 +29,183 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) = return () => clearInterval(timer); }, [meeting.id, meeting.status]); - if (meeting.status !== 1 && meeting.status !== 2) return null; + const statusConfig: Record = { + 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, + 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, + 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, + 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, + 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' } + }; + const config = statusConfig[meeting.status] || statusConfig[0]; const percent = progress?.percent || 0; - const isError = percent < 0; + const isProcessing = meeting.status === 1 || meeting.status === 2; return ( -
-
- - {progress?.message || '处理中...'} - - {!isError && {percent}%} -
- +
+ {/* 进度填充背景 */} + {isProcessing && percent > 0 && ( +
+ )} + + + {isProcessing ? : null} + {config.text} + {isProcessing && {percent}%} +
); }; +// 新增:提取进度信息 Hook 供卡片内部使用 +const useMeetingProgress = (meeting: MeetingVO) => { + 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]); + + return progress; +}; + +const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void }> = ({ item, config, fetchData }) => { + const navigate = useNavigate(); + const progress = useMeetingProgress(item); + const isProcessing = item.status === 1 || item.status === 2; + + return ( + + 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%' }} + > + {/* 左侧状态装饰条 - 增加分析中的呼吸灯效果 */} +
+ +
+ + {/* 右上角醒目图标 */} +
e.stopPropagation()}> + + +
navigate(`/meetings/${item.id}`)}> + +
+
+ deleteMeeting(item.id).then(fetchData)}> + +
+ +
+
+
+
+
+ + {/* 内容排版 */} +
+
+ +
+ +
+ + {item.title} + +
+ + +
+ + {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} +
+ + {isProcessing ? ( +
+ + + {progress?.message || '等待引擎调度...'} + +
+ ) : ( +
+ + {item.participants || '无参与人员'} +
+ )} +
+
+ + {/* 底部详情提示 */} +
+
+ {item.tags?.split(',').slice(0, 2).map(t => ( + {t} + ))} +
+ +
+
+
+
+ ); +}; + const Meetings: React.FC = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -146,98 +298,14 @@ const Meetings: React.FC = () => { dataSource={data} renderItem={(item) => { const config = statusConfig[item.status] || statusConfig[0]; - return ( - - navigate(`/meetings/${item.id}`)} - className="meeting-card" - style={{ - borderRadius: 16, - border: 'none', - 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', flexDirection: 'column' }} - > -
- {/* 左侧状态装饰条 */} -
- -
- - {/* 右上角醒目图标 */} -
e.stopPropagation()}> - - -
navigate(`/meetings/${item.id}`)}> - -
-
- deleteMeeting(item.id).then(fetchData)}> - -
- -
-
-
-
-
- - {/* 内容排版 */} -
-
- - {item.status === 1 || item.status === 2 ? : null} - {config.text} - -
- -
- - {item.title} - -
- - -
- - {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} -
-
- - {item.participants || '无参与人员'} -
-
-
- - {/* 底部详情提示 */} -
-
- {item.tags?.split(',').slice(0, 2).map(t => ( - {t} - ))} -
- -
-
-
- - {/* 进度条显示 */} - -
-
- ); + return ; }} locale={{ emptyText: }} />
- {/* 精美底部分页 */} + {/* 分页 */} {total > 0 && (
{ transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; } + .status-bar-active { + animation: statusBreathing 2s infinite ease-in-out; + } + @keyframes statusBreathing { + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } + } .icon-btn { width: 32px; height: 32px;