feat: 添加 ASR 任务重新调度功能并优化代理配置

- 在 `AiTaskService` 中添加 `triggerQueuedAsrScheduling` 和 `retryScheduleMeeting` 方法
- 更新多个服务实现类以使用新的 ASR 任务调度方法
- 在前端 `MeetingDetail.tsx` 和 `Meetings.tsx` 中添加重新调度按钮和相关逻辑
dev_na
chenhao 2026-06-01 13:42:50 +08:00
parent 92a12c4c81
commit 8a082732df
11 changed files with 238 additions and 77 deletions

View File

@ -3,9 +3,11 @@ package com.imeeting;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableAsync @EnableAsync
@EnableScheduling
public class ImeetingApplication { public class ImeetingApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(ImeetingApplication.class, args); SpringApplication.run(ImeetingApplication.class, args);

View File

@ -87,6 +87,7 @@ public class MeetingController {
private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final SysParamService sysParamService; private final SysParamService sysParamService;
private final AiTaskService aiTaskService;
private AiTaskService compatibilityAiTaskService; private AiTaskService compatibilityAiTaskService;
@Autowired @Autowired
@ -100,7 +101,8 @@ public class MeetingController {
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
MeetingAudioUploadSupport meetingAudioUploadSupport, MeetingAudioUploadSupport meetingAudioUploadSupport,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
SysParamService sysParamService) { SysParamService sysParamService,
AiTaskService aiTaskService) {
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService; this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService; this.meetingAccessService = meetingAccessService;
@ -112,6 +114,7 @@ public class MeetingController {
this.meetingAudioUploadSupport = meetingAudioUploadSupport; this.meetingAudioUploadSupport = meetingAudioUploadSupport;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.sysParamService = sysParamService; this.sysParamService = sysParamService;
this.aiTaskService = aiTaskService;
} }
public MeetingController(MeetingQueryService meetingQueryService, public MeetingController(MeetingQueryService meetingQueryService,
@ -137,7 +140,8 @@ public class MeetingController {
realtimeMeetingSessionStateService, realtimeMeetingSessionStateService,
meetingAudioUploadSupport, meetingAudioUploadSupport,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()), new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()),
sysParamService sysParamService,
unusedAiTaskService
); );
this.compatibilityAiTaskService = unusedAiTaskService; this.compatibilityAiTaskService = unusedAiTaskService;
} }
@ -203,6 +207,17 @@ public class MeetingController {
} }
return ApiResponse.ok(result); 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 = "上传会议音频") @Operation(summary = "上传会议音频")
@PostMapping("/upload") @PostMapping("/upload")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

@ -15,6 +15,6 @@ public class MeetingTaskDispatchListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onMeetingCreated(MeetingCreatedEvent event) { public void onMeetingCreated(MeetingCreatedEvent event) {
aiTaskService.dispatchTasks(event.getMeetingId(), event.getTenantId(), event.getUserId()); aiTaskService.triggerQueuedAsrScheduling();
} }
} }

View File

@ -301,13 +301,13 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) { private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchTasks(meetingId, tenantId, userId); aiTaskService.triggerQueuedAsrScheduling();
return; return;
} }
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
aiTaskService.dispatchTasks(meetingId, tenantId, userId); aiTaskService.triggerQueuedAsrScheduling();
} }
}); });
} }

View File

@ -5,6 +5,8 @@ import com.imeeting.entity.biz.AiTask;
public interface AiTaskService extends IService<AiTask> { public interface AiTaskService extends IService<AiTask> {
void dispatchTasks(Long meetingId, Long tenantId, Long userId); void dispatchTasks(Long meetingId, Long tenantId, Long userId);
void triggerQueuedAsrScheduling();
boolean retryScheduleMeeting(Long meetingId);
void dispatchChapterTask(Long meetingId, Long tenantId, Long userId); void dispatchChapterTask(Long meetingId, Long tenantId, Long userId);
void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId); void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId);
void reconcileMeetingStatus(Long meetingId); void reconcileMeetingStatus(Long meetingId);

View File

@ -40,6 +40,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
@ -190,6 +191,30 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
asrTaskExecutor.execute(task); 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) { private void doDispatchTasks(Long meetingId) {
String lockKey = RedisKeys.meetingPollingLockKey(meetingId); String lockKey = RedisKeys.meetingPollingLockKey(meetingId);
Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES); 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()); asrTask.setQueuedAt(LocalDateTime.now());
this.updateById(asrTask); this.updateById(asrTask);
} }
if (!claimQueuedAsrTask(asrTask)) {
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution"); meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
return; return;
} }
} }
}
String asrText = ""; String asrText = "";
if (asrTask != null && canExecuteTask(asrTask)) { if (asrTask != null && canExecuteTask(asrTask)) {
@ -340,9 +363,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
Long asrModelId = extractAsrModelId(asrTask); Long asrModelId = extractAsrModelId(asrTask);
String externalTaskId = extractExternalTaskId(asrTask); String externalTaskId = extractExternalTaskId(asrTask);
if (asrModelId == null || externalTaskId == null || externalTaskId.isBlank()) { // Freshly re-scheduled tasks are also claimed as RUNNING before they get a new external task id.
requeueAsrTask(asrTask, "恢复时缺少有效外部 ASR 任务,已重新排队", true); // In that case we should continue into processAsrTask() and submit a brand-new ASR job instead of
return false; // 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"); AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
if (asrModel == null || !canResumeAsrTask(asrModel, meeting.getId(), externalTaskId)) { if (asrModel == null || !canResumeAsrTask(asrModel, meeting.getId(), externalTaskId)) {
@ -352,63 +380,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return true; 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() { private void scheduleQueuedAsrTasks() {
if (getBaseMapper() == null) { if (getBaseMapper() == null) {
return; return;
} }
long queuedCount = count(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getTaskType, "ASR")
.eq(AiTask::getStatus, 0));
if (queuedCount <= 0) {
return;
}
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey(); String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS); Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(acquired)) { if (Boolean.FALSE.equals(acquired)) {
@ -423,15 +404,21 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (available <= 0) { if (available <= 0) {
return; return;
} }
refreshQueuedAsrProgress();
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>() List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getTaskType, "ASR") .eq(AiTask::getTaskType, "ASR")
.eq(AiTask::getStatus, 0) .eq(AiTask::getStatus, 0)
.orderByAsc(AiTask::getQueuedAt) .orderByAsc(AiTask::getQueuedAt)
.orderByAsc(AiTask::getId) .orderByAsc(AiTask::getId)
.last("LIMIT " + available)); .last("LIMIT " + available));
List<AiTask> claimedTasks = new ArrayList<>();
for (AiTask queuedTask : queuedTasks) { 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) { if (queuedMeeting == null) {
continue; 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() { private int resolveAsrMaxConcurrent() {
if (sysParamService == null) { if (sysParamService == null) {
return 2; return 2;
@ -489,6 +497,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
updateById(task); updateById(task);
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason); meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
refreshQueuedAsrProgress(); refreshQueuedAsrProgress();
triggerQueuedAsrScheduling();
} }
private Long extractAsrModelId(AiTask task) { 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 { 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); Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
taskRecord.setRequestData(req); taskRecord.setRequestData(req);
this.updateById(taskRecord); this.updateById(taskRecord);
String respBody = postJson(submitUrl, req, asrModel.getApiKey()); String respBody = postJson(submitUrl, req, asrModel.getApiKey());
JsonNode submitNode = objectMapper.readTree(respBody); 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); updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
throw new RuntimeException("ASR识别失败: " + firstNonBlank( throw new RuntimeException("ASR识别失败: " + firstNonBlank(
submitNode.path("message").asText(""), submitNode.path("message").asText(""),

View File

@ -409,7 +409,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
prepareOfflineReprocessTasks(meetingId, currentStatus); prepareOfflineReprocessTasks(meetingId, currentStatus);
realtimeMeetingSessionStateService.clear(meetingId); realtimeMeetingSessionStateService.clear(meetingId);
updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0); updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0);
aiTaskService.dispatchTasks(meetingId, meeting.getTenantId(), meeting.getCreatorId()); aiTaskService.triggerQueuedAsrScheduling();
return; return;
} }
@ -1196,13 +1196,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) { private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchTasks(meetingId, tenantId, userId); aiTaskService.triggerQueuedAsrScheduling();
return; return;
} }
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
aiTaskService.dispatchTasks(meetingId, tenantId, userId); aiTaskService.triggerQueuedAsrScheduling();
} }
}); });
} }

View File

@ -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();
}
}

View File

@ -442,6 +442,7 @@ export interface MeetingProgress {
updateAt: number; updateAt: number;
eta?: number; eta?: number;
queueAheadCount?: number; queueAheadCount?: number;
queuedAt?: string;
} }
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => { 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") => { export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
const token = localStorage.getItem("accessToken"); const token = localStorage.getItem("accessToken");
return axios.get(`/api/biz/meeting/${id}/summary/export`, { return axios.get(`/api/biz/meeting/${id}/summary/export`, {

View File

@ -43,6 +43,7 @@ import {
retryMeetingChapter, retryMeetingChapter,
retryMeetingSummary, retryMeetingSummary,
retryMeetingTranscription, retryMeetingTranscription,
retryScheduleMeeting,
updateMeetingBasic, updateMeetingBasic,
updateMeetingTranscript, updateMeetingTranscript,
updateMeetingSummary, updateMeetingSummary,
@ -106,6 +107,8 @@ type MeetingStateNotice = {
scope: 'summary' | 'catalog' | 'global'; scope: 'summary' | 'catalog' | 'global';
}; };
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
const ANALYSIS_EMPTY: MeetingAnalysis = { const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: '', overview: '',
keywords: [], keywords: [],
@ -1026,6 +1029,27 @@ const MeetingDetail: React.FC = () => {
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2; const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 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 hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
const hasCatalogContent = catalogChapterLinks.length > 0; const hasCatalogContent = catalogChapterLinks.length > 0;
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => { const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
@ -2016,6 +2040,11 @@ const MeetingDetail: React.FC = () => {
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}> <Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
</Button> </Button>
{canRetrySchedule && (
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
</Button>
)}
{canRetrySummary && ( {canRetrySummary && (
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}> <Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>

View File

@ -55,6 +55,7 @@ import {
type MeetingProgress, type MeetingProgress,
type MeetingVO, type MeetingVO,
type RealtimeMeetingSessionStatus, type RealtimeMeetingSessionStatus,
retryScheduleMeeting,
updateMeetingParticipants, updateMeetingParticipants,
} from "../../api/business/meeting"; } from "../../api/business/meeting";
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer"; 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_ACTIVE_DISPLAY_STATUS = 6;
const REALTIME_IDLE_DISPLAY_STATUS = 7; const REALTIME_IDLE_DISPLAY_STATUS = 7;
const ALL_STATUS_FILTER = "all"; const ALL_STATUS_FILTER = "all";
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
const MEETING_STATUS_FILTER_OPTIONS = [ const MEETING_STATUS_FILTER_OPTIONS = [
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" }, { label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "排队中", value: "0", 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 => { const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
if (!sessionStatus) { if (!sessionStatus) {
return item; return item;
@ -225,9 +237,10 @@ const MeetingCardItem: React.FC<{
item: MeetingVO; item: MeetingVO;
config: { text: string; color: string; bgColor: string }; config: { text: string; color: string; bgColor: string };
progress: MeetingProgress | null; progress: MeetingProgress | null;
fetchData: () => void;
onOpenMeeting: (meeting: MeetingVO) => 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 effectiveStatus = getEffectiveStatus(item, progress);
const isProcessing = shouldTrackGenerationProgress(item); const isProcessing = shouldTrackGenerationProgress(item);
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
@ -235,6 +248,7 @@ const MeetingCardItem: React.FC<{
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
const crossPlatformHint = `${getRealtimeSourceLabel(item)}继续`; const crossPlatformHint = `${getRealtimeSourceLabel(item)}继续`;
const canRetry = canRetryQueuedMeeting(item, progress);
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6"; const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
@ -325,6 +339,20 @@ const MeetingCardItem: React.FC<{
{isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)} {isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
</span> </span>
</div> </div>
{canRetry && (
<Button
type="link"
size="small"
loading={retrying}
onClick={(event) => {
event.stopPropagation();
onRetrySchedule(item);
}}
style={{ paddingInline: 0, height: "auto" }}
>
</Button>
)}
</div> </div>
) : ( ) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}> <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 [userList, setUserList] = useState<SysUser[]>([]);
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map()); 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); 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) => { const handleOpenMeeting = async (meeting: MeetingVO) => {
if (!isRealtimeMeetingCandidate(meeting)) { if (!isRealtimeMeetingCandidate(meeting)) {
navigate("/meetings/" + meeting.id); navigate("/meetings/" + meeting.id);
@ -700,10 +750,23 @@ const Meetings: React.FC = () => {
{ {
title: "操作", title: "操作",
key: "action", key: "action",
width: 160, width: 220,
render: (_: unknown, record: MeetingVO) => ( render: (_: unknown, record: MeetingVO) => (
<Space size="middle"> <Space size="middle">
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleOpenMeeting(record); }}></Button> <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) && ( {canManageMeeting(record) && (
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}> <Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button> <Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button>
@ -840,8 +903,9 @@ const Meetings: React.FC = () => {
item={item} item={item}
config={config} config={config}
progress={progress} progress={progress}
fetchData={() => void fetchData()}
onOpenMeeting={handleOpenMeeting} onOpenMeeting={handleOpenMeeting}
onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }}
retrying={!!retryingMeetingIds[item.id]}
/> />
); );
}} }}