feat: 添加会议进度显示和优化任务处理逻辑
- 在前端添加会议进度显示组件 - 优化后端任务调度逻辑,增加轮询锁防止并发执行 - 更新ASR和LLM任务处理流程,同步进度到Redis - 重构会议详情页,展示AI分析进度和状态 - 修复和优化多处代码逻辑和样式问题dev_na
parent
80a4682757
commit
37025d3f02
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<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")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
||||
|
|
|
|||
|
|
@ -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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<String, Object> 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<Map<String, Object>> formattedHotwords = new ArrayList<>();
|
||||
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())
|
||||
.in(HotWord::getWord, meeting.getHotWords()));
|
||||
|
||||
Map<String, Integer> wordToWeightMap = hotWordEntities.stream()
|
||||
.collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1));
|
||||
|
||||
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);
|
||||
Map<String, Integer> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> impleme
|
|||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("model", llmModel.getModelCode());
|
||||
req.put("temperature", llmModel.getTemperature());
|
||||
|
||||
List<Map<String, String>> 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<AiTaskMapper, AiTask> 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<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 {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
|
|
@ -289,7 +318,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> 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);
|
||||
|
|
|
|||
|
|
@ -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<any, { code: string; data: MeetingProgress; msg: string }>(
|
||||
`/api/biz/meeting/${id}/progress`
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
|
|
@ -147,6 +193,9 @@ const Dashboard: React.FC = () => {
|
|||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 进度实时展示 */}
|
||||
<MeetingProgressDisplay meeting={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<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<{
|
||||
meetingId: number;
|
||||
speakerId: string;
|
||||
|
|
@ -231,36 +303,45 @@ const MeetingDetail: React.FC = () => {
|
|||
</Card>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
<Col span={12} style={{ height: '100%' }}>
|
||||
<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' }} />}>
|
||||
<List dataSource={transcripts} renderItem={(item) => (
|
||||
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}>
|
||||
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
|
||||
title={<Space>
|
||||
{isOwner ? (
|
||||
<Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click">
|
||||
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span>
|
||||
</Popover>
|
||||
) : (
|
||||
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
|
||||
)}
|
||||
{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>
|
||||
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} />
|
||||
</List.Item>
|
||||
)} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12} style={{ height: '100%' }}>
|
||||
<Card title={<span><RobotOutlined /> AI 总结</span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}>
|
||||
{meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> :
|
||||
<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>
|
||||
{(meeting.status === 1 || meeting.status === 2) ? (
|
||||
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
|
||||
) : (
|
||||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
<Col span={12} style={{ height: '100%' }}>
|
||||
<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' }} />}>
|
||||
<List dataSource={transcripts} renderItem={(item) => (
|
||||
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}>
|
||||
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
|
||||
title={<Space>
|
||||
{isOwner ? (
|
||||
<Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click">
|
||||
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span>
|
||||
</Popover>
|
||||
) : (
|
||||
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
|
||||
)}
|
||||
{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>
|
||||
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} />
|
||||
</List.Item>
|
||||
)} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12} style={{ height: '100%' }}>
|
||||
<Card title={<span><RobotOutlined /> AI 总结</span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}>
|
||||
{meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> :
|
||||
<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>
|
||||
<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 */}
|
||||
{isOwner && (
|
||||
|
|
|
|||
|
|
@ -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<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 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' }}
|
||||
>
|
||||
{/* 左侧状态装饰条 */}
|
||||
<div style={{ width: 6, height: '100%', backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
||||
|
||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flex: 1 }}>
|
||||
{/* 左侧状态装饰条 */}
|
||||
<div style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
||||
|
||||
{/* 右上角醒目图标 */}
|
||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
||||
<Space size={8}>
|
||||
<Tooltip title="编辑">
|
||||
<div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}>
|
||||
<EditOutlined />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => deleteMeeting(item.id).then(fetchData)}>
|
||||
<Tooltip title="删除">
|
||||
<div className="icon-btn delete">
|
||||
<DeleteOutlined />
|
||||
<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="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}>
|
||||
<EditOutlined />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 内容排版 */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color={config.bgColor} style={{ color: config.color, border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11 }}>
|
||||
{item.status === 1 || item.status === 2 ? <LoadingOutlined spin style={{ marginRight: 4 }} /> : null}
|
||||
{config.text}
|
||||
</Tag>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => deleteMeeting(item.id).then(fetchData)}>
|
||||
<Tooltip title="删除">
|
||||
<div className="icon-btn delete">
|
||||
<DeleteOutlined />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}>
|
||||
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</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 style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color={config.bgColor} style={{ color: config.color, border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11 }}>
|
||||
{item.status === 1 || item.status === 2 ? <LoadingOutlined spin style={{ marginRight: 4 }} /> : null}
|
||||
{config.text}
|
||||
</Tag>
|
||||
</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 style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}>
|
||||
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</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>
|
||||
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条显示 */}
|
||||
<MeetingProgressDisplay meeting={item} />
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue