feat: 添加 ASR 任务重新调度功能并优化代理配置
- 在 `AiTaskService` 中添加 `triggerQueuedAsrScheduling` 和 `retryScheduleMeeting` 方法 - 更新多个服务实现类以使用新的 ASR 任务调度方法 - 在前端 `MeetingDetail.tsx` 和 `Meetings.tsx` 中添加重新调度按钮和相关逻辑dev_na
parent
92a12c4c81
commit
8a082732df
|
|
@ -3,9 +3,11 @@ package com.imeeting;
|
|||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class ImeetingApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImeetingApplication.class, args);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ public class MeetingController {
|
|||
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
||||
private final MeetingProgressService meetingProgressService;
|
||||
private final SysParamService sysParamService;
|
||||
private final AiTaskService aiTaskService;
|
||||
private AiTaskService compatibilityAiTaskService;
|
||||
|
||||
@Autowired
|
||||
|
|
@ -100,7 +101,8 @@ public class MeetingController {
|
|||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||
MeetingAudioUploadSupport meetingAudioUploadSupport,
|
||||
MeetingProgressService meetingProgressService,
|
||||
SysParamService sysParamService) {
|
||||
SysParamService sysParamService,
|
||||
AiTaskService aiTaskService) {
|
||||
this.meetingQueryService = meetingQueryService;
|
||||
this.meetingCommandService = meetingCommandService;
|
||||
this.meetingAccessService = meetingAccessService;
|
||||
|
|
@ -112,6 +114,7 @@ public class MeetingController {
|
|||
this.meetingAudioUploadSupport = meetingAudioUploadSupport;
|
||||
this.meetingProgressService = meetingProgressService;
|
||||
this.sysParamService = sysParamService;
|
||||
this.aiTaskService = aiTaskService;
|
||||
}
|
||||
|
||||
public MeetingController(MeetingQueryService meetingQueryService,
|
||||
|
|
@ -137,7 +140,8 @@ public class MeetingController {
|
|||
realtimeMeetingSessionStateService,
|
||||
meetingAudioUploadSupport,
|
||||
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||
sysParamService
|
||||
sysParamService,
|
||||
unusedAiTaskService
|
||||
);
|
||||
this.compatibilityAiTaskService = unusedAiTaskService;
|
||||
}
|
||||
|
|
@ -203,6 +207,17 @@ public class MeetingController {
|
|||
}
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "重新调度排队中的会议 ASR 任务")
|
||||
@PostMapping("/{id}/retry-schedule")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> retrySchedule(@PathVariable Long id) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||
return ApiResponse.ok(aiTaskService.retryScheduleMeeting(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "上传会议音频")
|
||||
@PostMapping("/upload")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ public class MeetingTaskDispatchListener {
|
|||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onMeetingCreated(MeetingCreatedEvent event) {
|
||||
aiTaskService.dispatchTasks(event.getMeetingId(), event.getTenantId(), event.getUserId());
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,13 +301,13 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
|
||||
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import com.imeeting.entity.biz.AiTask;
|
|||
|
||||
public interface AiTaskService extends IService<AiTask> {
|
||||
void dispatchTasks(Long meetingId, Long tenantId, Long userId);
|
||||
void triggerQueuedAsrScheduling();
|
||||
boolean retryScheduleMeeting(Long meetingId);
|
||||
void dispatchChapterTask(Long meetingId, Long tenantId, Long userId);
|
||||
void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId);
|
||||
void reconcileMeetingStatus(Long meetingId);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
|
|
@ -190,6 +191,30 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
asrTaskExecutor.execute(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerQueuedAsrScheduling() {
|
||||
scheduleQueuedAsrTasks();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryScheduleMeeting(Long meetingId) {
|
||||
if (meetingId == null) {
|
||||
return false;
|
||||
}
|
||||
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
||||
if (asrTask == null || !Integer.valueOf(0).equals(asrTask.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (asrTask.getQueuedAt() == null) {
|
||||
asrTask.setQueuedAt(LocalDateTime.now());
|
||||
updateById(asrTask);
|
||||
}
|
||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "已触发重新调度");
|
||||
triggerQueuedAsrScheduling();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private void doDispatchTasks(Long meetingId) {
|
||||
String lockKey = RedisKeys.meetingPollingLockKey(meetingId);
|
||||
Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES);
|
||||
|
|
@ -218,12 +243,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
asrTask.setQueuedAt(LocalDateTime.now());
|
||||
this.updateById(asrTask);
|
||||
}
|
||||
if (!claimQueuedAsrTask(asrTask)) {
|
||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String asrText = "";
|
||||
if (asrTask != null && canExecuteTask(asrTask)) {
|
||||
|
|
@ -340,9 +363,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
Long asrModelId = extractAsrModelId(asrTask);
|
||||
String externalTaskId = extractExternalTaskId(asrTask);
|
||||
if (asrModelId == null || externalTaskId == null || externalTaskId.isBlank()) {
|
||||
requeueAsrTask(asrTask, "恢复时缺少有效外部 ASR 任务,已重新排队", true);
|
||||
return false;
|
||||
// Freshly re-scheduled tasks are also claimed as RUNNING before they get a new external task id.
|
||||
// In that case we should continue into processAsrTask() and submit a brand-new ASR job instead of
|
||||
// treating them as a broken recovery candidate and requeueing forever.
|
||||
if (externalTaskId == null || externalTaskId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
if (asrModelId == null) {
|
||||
return true;
|
||||
}
|
||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null || !canResumeAsrTask(asrModel, meeting.getId(), externalTaskId)) {
|
||||
|
|
@ -352,63 +380,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return true;
|
||||
}
|
||||
|
||||
private boolean claimQueuedAsrTask(AiTask task) {
|
||||
if (task == null || task.getId() == null || !Integer.valueOf(0).equals(task.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (getBaseMapper() == null) {
|
||||
task.setStatus(1);
|
||||
task.setStartedAt(LocalDateTime.now());
|
||||
return true;
|
||||
}
|
||||
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
|
||||
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
|
||||
if (Boolean.FALSE.equals(acquired)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int maxConcurrent = resolveAsrMaxConcurrent();
|
||||
long runningCount = count(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.eq(AiTask::getStatus, 1));
|
||||
if (runningCount >= maxConcurrent) {
|
||||
return false;
|
||||
}
|
||||
int available = (int) Math.max(1, maxConcurrent - runningCount);
|
||||
List<AiTask> dispatchable = list(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.eq(AiTask::getStatus, 0)
|
||||
.orderByAsc(AiTask::getQueuedAt)
|
||||
.orderByAsc(AiTask::getId)
|
||||
.last("LIMIT " + available));
|
||||
boolean eligible = dispatchable.stream().anyMatch(item -> Objects.equals(item.getId(), task.getId()));
|
||||
if (!eligible) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean claimed = update(new LambdaUpdateWrapper<AiTask>()
|
||||
.eq(AiTask::getId, task.getId())
|
||||
.eq(AiTask::getStatus, 0)
|
||||
.set(AiTask::getStatus, 1)
|
||||
.set(AiTask::getStartedAt, now)
|
||||
.set(AiTask::getCompletedAt, null)
|
||||
.set(AiTask::getErrorMsg, null));
|
||||
if (claimed) {
|
||||
task.setStatus(1);
|
||||
task.setStartedAt(now);
|
||||
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
||||
refreshQueuedAsrProgress();
|
||||
}
|
||||
return claimed;
|
||||
} finally {
|
||||
redisValueSupport.delete(scheduleLockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleQueuedAsrTasks() {
|
||||
if (getBaseMapper() == null) {
|
||||
return;
|
||||
}
|
||||
long queuedCount = count(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.eq(AiTask::getStatus, 0));
|
||||
if (queuedCount <= 0) {
|
||||
return;
|
||||
}
|
||||
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
|
||||
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
|
||||
if (Boolean.FALSE.equals(acquired)) {
|
||||
|
|
@ -423,15 +404,21 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (available <= 0) {
|
||||
return;
|
||||
}
|
||||
refreshQueuedAsrProgress();
|
||||
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.eq(AiTask::getStatus, 0)
|
||||
.orderByAsc(AiTask::getQueuedAt)
|
||||
.orderByAsc(AiTask::getId)
|
||||
.last("LIMIT " + available));
|
||||
List<AiTask> claimedTasks = new ArrayList<>();
|
||||
for (AiTask queuedTask : queuedTasks) {
|
||||
Meeting queuedMeeting = meetingMapper.selectById(queuedTask.getMeetingId());
|
||||
if (claimQueuedAsrTaskForScheduling(queuedTask)) {
|
||||
claimedTasks.add(queuedTask);
|
||||
}
|
||||
}
|
||||
refreshQueuedAsrProgress();
|
||||
for (AiTask queuedTask : claimedTasks) {
|
||||
Meeting queuedMeeting = meetingMapper.selectByIdIgnoreTenant(queuedTask.getMeetingId());
|
||||
if (queuedMeeting == null) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -456,6 +443,27 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
}
|
||||
|
||||
private boolean claimQueuedAsrTaskForScheduling(AiTask task) {
|
||||
if (task == null || task.getId() == null || !Integer.valueOf(0).equals(task.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean claimed = update(new LambdaUpdateWrapper<AiTask>()
|
||||
.eq(AiTask::getId, task.getId())
|
||||
.eq(AiTask::getStatus, 0)
|
||||
.set(AiTask::getStatus, 1)
|
||||
.set(AiTask::getStartedAt, now)
|
||||
.set(AiTask::getCompletedAt, null)
|
||||
.set(AiTask::getErrorMsg, null));
|
||||
if (!claimed) {
|
||||
return false;
|
||||
}
|
||||
task.setStatus(1);
|
||||
task.setStartedAt(now);
|
||||
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
private int resolveAsrMaxConcurrent() {
|
||||
if (sysParamService == null) {
|
||||
return 2;
|
||||
|
|
@ -489,6 +497,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
updateById(task);
|
||||
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
||||
refreshQueuedAsrProgress();
|
||||
triggerQueuedAsrScheduling();
|
||||
}
|
||||
|
||||
private Long extractAsrModelId(AiTask task) {
|
||||
|
|
@ -666,14 +675,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
|
||||
private String submitAsrTask(Meeting meeting, AiTask taskRecord, AiModelVO asrModel, String submitUrl) throws Exception {
|
||||
updateProgress(meeting.getId(), 5, "重新提交任务...", 0);
|
||||
updateProgress(meeting.getId(), 5, "提交任务...", 0);
|
||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||
taskRecord.setRequestData(req);
|
||||
this.updateById(taskRecord);
|
||||
|
||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||
if (submitNode.path("code").asInt() != 0) {
|
||||
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
|
||||
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
|
||||
submitNode.path("message").asText(""),
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
prepareOfflineReprocessTasks(meetingId, currentStatus);
|
||||
realtimeMeetingSessionStateService.clear(meetingId);
|
||||
updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0);
|
||||
aiTaskService.dispatchTasks(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1196,13 +1196,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
|
||||
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.imeeting.task;
|
||||
|
||||
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* @author : ch
|
||||
* @version : 1.0
|
||||
* @ClassName : AiTaskConfig
|
||||
* @Description :
|
||||
* @DATE : Created in 10:29 2026/6/1
|
||||
* <pre> Copyright: Copyright(c) 2026 </pre>
|
||||
* <pre> Company : 紫光汇智信息技术有限公司 </pre>
|
||||
* Modification History:
|
||||
* Date Author Version Discription
|
||||
* --------------------------------------------------------------------------
|
||||
* 2026/06/01 ch 1.0 Why & What is modified: <修改原因描述> *
|
||||
*/
|
||||
@Component
|
||||
public class AiTaskConfig {
|
||||
@Autowired
|
||||
private AiTaskService aiTaskService;
|
||||
@Scheduled(
|
||||
fixedDelayString = "${imeeting.asr-schedule-fixed-delay-ms:15000}",
|
||||
initialDelayString = "${imeeting.asr-schedule-initial-delay-ms:15000}"
|
||||
)
|
||||
public void compensateQueuedAsrScheduling() {
|
||||
aiTaskService.triggerQueuedAsrScheduling();
|
||||
}
|
||||
}
|
||||
|
|
@ -442,6 +442,7 @@ export interface MeetingProgress {
|
|||
updateAt: number;
|
||||
eta?: number;
|
||||
queueAheadCount?: number;
|
||||
queuedAt?: string;
|
||||
}
|
||||
|
||||
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
|
||||
|
|
@ -463,6 +464,12 @@ export const getMeetingProgressBatch = (ids: number[], options?: { suppressError
|
|||
);
|
||||
};
|
||||
|
||||
export const retryScheduleMeeting = (id: number) => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${id}/retry-schedule`
|
||||
);
|
||||
};
|
||||
|
||||
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
retryMeetingChapter,
|
||||
retryMeetingSummary,
|
||||
retryMeetingTranscription,
|
||||
retryScheduleMeeting,
|
||||
updateMeetingBasic,
|
||||
updateMeetingTranscript,
|
||||
updateMeetingSummary,
|
||||
|
|
@ -106,6 +107,8 @@ type MeetingStateNotice = {
|
|||
scope: 'summary' | 'catalog' | 'global';
|
||||
};
|
||||
|
||||
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
|
||||
|
||||
const ANALYSIS_EMPTY: MeetingAnalysis = {
|
||||
overview: '',
|
||||
keywords: [],
|
||||
|
|
@ -1026,6 +1029,27 @@ const MeetingDetail: React.FC = () => {
|
|||
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
|
||||
const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
|
||||
|
||||
const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
|
||||
|
||||
const handleRetrySchedule = async () => {
|
||||
if (!meeting) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await retryScheduleMeeting(meeting.id);
|
||||
if (response.data?.data) {
|
||||
message.success("已触发重新调度");
|
||||
} else {
|
||||
message.info("当前没有可重新调度的排队任务");
|
||||
}
|
||||
await fetchData(meeting.id);
|
||||
} catch {
|
||||
message.error("重新调度失败");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
|
||||
const hasCatalogContent = catalogChapterLinks.length > 0;
|
||||
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
|
||||
|
|
@ -2016,6 +2040,11 @@ const MeetingDetail: React.FC = () => {
|
|||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
||||
返回
|
||||
</Button>
|
||||
{canRetrySchedule && (
|
||||
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
|
||||
重新调度
|
||||
</Button>
|
||||
)}
|
||||
{canRetrySummary && (
|
||||
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
||||
重新总结
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
type MeetingProgress,
|
||||
type MeetingVO,
|
||||
type RealtimeMeetingSessionStatus,
|
||||
retryScheduleMeeting,
|
||||
updateMeetingParticipants,
|
||||
} from "../../api/business/meeting";
|
||||
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer";
|
||||
|
|
@ -72,6 +73,7 @@ const PAUSED_DISPLAY_STATUS = 5;
|
|||
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
||||
const REALTIME_IDLE_DISPLAY_STATUS = 7;
|
||||
const ALL_STATUS_FILTER = "all";
|
||||
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
|
||||
const MEETING_STATUS_FILTER_OPTIONS = [
|
||||
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||
|
|
@ -145,6 +147,16 @@ const canManageMeeting = (meeting: MeetingVO) => {
|
|||
}
|
||||
};
|
||||
|
||||
const canRetryQueuedMeeting = (meeting: MeetingVO, progress: MeetingProgress | null) => {
|
||||
if (!canManageMeeting(meeting) || getEffectiveStatus(meeting, progress) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (!progress?.queuedAt) {
|
||||
return false;
|
||||
}
|
||||
return dayjs().diff(dayjs(progress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
|
||||
};
|
||||
|
||||
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
|
||||
if (!sessionStatus) {
|
||||
return item;
|
||||
|
|
@ -225,9 +237,10 @@ const MeetingCardItem: React.FC<{
|
|||
item: MeetingVO;
|
||||
config: { text: string; color: string; bgColor: string };
|
||||
progress: MeetingProgress | null;
|
||||
fetchData: () => void;
|
||||
onOpenMeeting: (meeting: MeetingVO) => void;
|
||||
}> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
|
||||
onRetrySchedule: (meeting: MeetingVO) => void;
|
||||
retrying: boolean;
|
||||
}> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, retrying }) => {
|
||||
const effectiveStatus = getEffectiveStatus(item, progress);
|
||||
const isProcessing = shouldTrackGenerationProgress(item);
|
||||
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
||||
|
|
@ -235,6 +248,7 @@ const MeetingCardItem: React.FC<{
|
|||
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
||||
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
|
||||
const crossPlatformHint = `在${getRealtimeSourceLabel(item)}继续`;
|
||||
const canRetry = canRetryQueuedMeeting(item, progress);
|
||||
|
||||
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
|
||||
|
||||
|
|
@ -325,6 +339,20 @@ const MeetingCardItem: React.FC<{
|
|||
{isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
|
||||
</span>
|
||||
</div>
|
||||
{canRetry && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={retrying}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRetrySchedule(item);
|
||||
}}
|
||||
style={{ paddingInline: 0, height: "auto" }}
|
||||
>
|
||||
重新调度
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}>
|
||||
|
|
@ -425,6 +453,7 @@ const Meetings: React.FC = () => {
|
|||
});
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
|
||||
const [retryingMeetingIds, setRetryingMeetingIds] = useState<Record<number, boolean>>({});
|
||||
|
||||
const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
|
||||
|
||||
|
|
@ -629,6 +658,27 @@ const Meetings: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRetrySchedule = async (meeting: MeetingVO) => {
|
||||
setRetryingMeetingIds((currentMap) => ({ ...currentMap, [meeting.id]: true }));
|
||||
try {
|
||||
const response = await retryScheduleMeeting(meeting.id);
|
||||
if (response.data?.data) {
|
||||
message.success("已触发重新调度");
|
||||
} else {
|
||||
message.info("当前没有可重新调度的排队任务");
|
||||
}
|
||||
await fetchData(true);
|
||||
} catch {
|
||||
message.error("重新调度失败");
|
||||
} finally {
|
||||
setRetryingMeetingIds((currentMap) => {
|
||||
const nextMap = { ...currentMap };
|
||||
delete nextMap[meeting.id];
|
||||
return nextMap;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMeeting = async (meeting: MeetingVO) => {
|
||||
if (!isRealtimeMeetingCandidate(meeting)) {
|
||||
navigate("/meetings/" + meeting.id);
|
||||
|
|
@ -700,10 +750,23 @@ const Meetings: React.FC = () => {
|
|||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 160,
|
||||
width: 220,
|
||||
render: (_: unknown, record: MeetingVO) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleOpenMeeting(record); }}>查看详情</Button>
|
||||
{canRetryQueuedMeeting(record, progressMap[record.id] || null) && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={!!retryingMeetingIds[record.id]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleRetrySchedule(record);
|
||||
}}
|
||||
>
|
||||
重新调度
|
||||
</Button>
|
||||
)}
|
||||
{canManageMeeting(record) && (
|
||||
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
|
||||
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}>删除</Button>
|
||||
|
|
@ -840,8 +903,9 @@ const Meetings: React.FC = () => {
|
|||
item={item}
|
||||
config={config}
|
||||
progress={progress}
|
||||
fetchData={() => void fetchData()}
|
||||
onOpenMeeting={handleOpenMeeting}
|
||||
onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }}
|
||||
retrying={!!retryingMeetingIds[item.id]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue