feat: 添加分页和优化会议任务处理逻辑

- 在前端 `Dashboard` 页面中添加分页功能
- 优化 `AiTaskServiceImpl` 中的 ASR 任务处理逻辑,支持任务恢复和失败处理
- 更新相关服务和组件以支持新的分页和任务处理逻辑
dev_na
chenhao 2026-05-13 18:12:25 +08:00
parent f7480df565
commit 7d08234919
2 changed files with 195 additions and 77 deletions

View File

@ -214,26 +214,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", ""))
: "";
if (taskId == null || taskId.isBlank()) {
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) {
updateAiTaskFail(taskRecord, "提交失败: " + respBody);
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
}
taskId = submitNode.path("data").path("task_id").asText();
taskRecord.setResponseData(Map.of("task_id", taskId));
this.updateById(taskRecord);
} else {
if (taskId != null && !taskId.isBlank()) {
updateProgress(meeting.getId(), 5, "Resuming ASR polling...", 0);
if (!canResumeAsrTask(asrModel, meeting.getId(), taskId)) {
clearAsrTaskId(taskRecord);
taskId = "";
}
}
if (taskId == null || taskId.isBlank()) {
taskId = submitAsrTask(meeting, taskRecord, asrModel, submitUrl);
}
this.updateById(taskRecord);
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
// 轮询逻辑(带防卡死防护)
@ -245,6 +237,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
Thread.sleep(2000);
String queryResp = get(queryUrl, asrModel.getApiKey());
JsonNode statusNode = objectMapper.readTree(queryResp);
int code = statusNode.path("code").asInt(500);
if (code!=0){
updateAiTaskFail(taskRecord, "ASR 引擎返回失败:" + queryResp);
throw new RuntimeException("ASR引擎处理失败: " + statusNode.get("message").asText());
}
JsonNode data = statusNode.path("data");
String status = data.path("status").asText();
@ -325,6 +322,58 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return req;
}
private boolean canResumeAsrTask(AiModelVO asrModel, Long meetingId, String taskId) {
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
try {
String queryResp = get(queryUrl, asrModel.getApiKey());
JsonNode statusNode = objectMapper.readTree(queryResp);
int code = statusNode.path("code").asInt(500);
if (code != 0) {
log.warn("ASR task {} progress fetch failed for meeting {}, will resubmit task. response={}",
taskId, meetingId, queryResp);
return false;
}
String status = statusNode.path("data").path("status").asText();
if ("failed".equalsIgnoreCase(status)) {
log.warn("ASR task {} already failed for meeting {}, will resubmit task.", taskId, meetingId);
return false;
}
return true;
} catch (Exception ex) {
log.warn("ASR task {} progress fetch threw exception for meeting {}, will resubmit task.",
taskId, meetingId, ex);
return false;
}
}
private void clearAsrTaskId(AiTask taskRecord) {
if (taskRecord.getResponseData() == null || taskRecord.getResponseData().isEmpty()) {
return;
}
Map<String, Object> responseData = new HashMap<>(taskRecord.getResponseData());
responseData.remove("task_id");
taskRecord.setResponseData(responseData.isEmpty() ? null : responseData);
this.updateById(taskRecord);
}
private String submitAsrTask(Meeting meeting, AiTask taskRecord, AiModelVO asrModel, String submitUrl) throws Exception {
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) {
updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody);
throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText());
}
String taskId = submitNode.path("data").path("task_id").asText();
taskRecord.setResponseData(Map.of("task_id", taskId));
this.updateById(taskRecord);
return taskId;
}
@Transactional(rollbackFor = Exception.class)
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
// 关键:入库前清理旧记录,防止恢复任务导致数据重复

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import PageContainer from "@/components/shared/PageContainer";
import AppPagination from '@/components/shared/AppPagination';
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import {
HistoryOutlined,
@ -16,8 +17,8 @@ import {
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
import { getDashboardStats, DashboardStats } from '@/api/business/dashboard';
import { MeetingVO, getMeetingPage, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
const { Title, Text } = Typography;
@ -33,12 +34,12 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
if (res.data?.data) {
setProgress(res.data.data);
}
} catch (err) {
} catch {
// ignore
}
};
fetchProgress();
void fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
@ -50,7 +51,7 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
return (
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'}
@ -73,33 +74,62 @@ export const Dashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const processingCount = Number(stats?.processingTasks || 0);
const dashboardLoading = loading && processingCount > 0;
const dashboardLoading = loading;
useEffect(() => {
fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000);
return () => clearInterval(timer);
}, []);
void fetchDashboardData();
}, [pagination.current, pagination.pageSize]);
const fetchDashboardData = async () => {
useEffect(() => {
const timer = setInterval(() => {
void fetchDashboardData(true);
}, 5000);
return () => clearInterval(timer);
}, [pagination.current, pagination.pageSize]);
const fetchDashboardData = async (silent = false) => {
if (!silent) {
setLoading(true);
}
try {
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
const [statsRes, tasksRes] = await Promise.all([
getDashboardStats(),
getMeetingPage({
current: pagination.current,
size: pagination.pageSize,
}),
]);
setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []);
setRecentTasks(tasksRes.data?.data?.records || []);
setPagination((prev) => ({
...prev,
total: tasksRes.data?.data?.total || 0,
}));
} catch (err) {
console.error('Dashboard data load failed', err);
} finally {
setLoading(false);
if (!silent) {
setLoading(false);
}
}
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize,
}));
};
const renderTaskProgress = (item: MeetingVO) => {
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return (
<div style={{ width: '100%', maxWidth: 450 }}>
<div style={{ width: '100%', maxWidth: '100%' }}>
<Steps
size="small"
current={currentStep}
@ -108,7 +138,7 @@ export const Dashboard: React.FC = () => {
{
title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI 转录中' : '排队中')
},
{
title: '智能总结',
@ -139,71 +169,77 @@ export const Dashboard: React.FC = () => {
return (
<PageContainer
title="仪表"
title="仪表"
subtitle="系统运行概览与最近任务动态"
style={{ overflow: 'hidden' }}
>
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((s, idx) => (
<Col span={6} key={idx}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0}
valueStyle={{ color: s.color, fontWeight: 700 }}
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
/>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 16, flexShrink: 0 }}>
{statCards.map((s, idx) => (
<Col xs={24} sm={12} xl={6} key={idx}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0}
valueStyle={{ color: s.color, fontWeight: 700 }}
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
/>
</Card>
</Col>
))}
</Row>
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button>
</div>
}
variant="borderless"
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
>
<Card
className="dashboard-task-card"
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button>
</div>
}
variant="borderless"
style={{ flex: 1, minHeight: 0, borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)', overflow: 'hidden' }}
styles={{ body: { display: 'flex', flexDirection: 'column', gap: 16, flex: 1, minHeight: 0, overflow: 'hidden',height:'90%' } }}
>
<div className="dashboard-task-list">
<List
loading={dashboardLoading}
dataSource={recentTasks}
renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}>
<Row gutter={32} align="middle">
<Col span={8}>
<Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
<Row gutter={[24, 16]} align="middle">
<Col xs={24} xl={8}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Title level={5} style={{ margin: 0, cursor: 'pointer', wordBreak: 'break-word' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title}
</Title>
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Space size={12} wrap split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
</Space>
<div style={{ marginTop: 8 }}>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{item.tags?.split(',').filter(Boolean).map((t) => (
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
<Tag key={t} style={{ marginInlineEnd: 0, border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
))}
</div>
</Space>
</Col>
<Col span={12}>
<Col xs={24} xl={12}>
{renderTaskProgress(item)}
</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Button
type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)}
>
{item.status === 3 ? '查看纪要' : '监控详情'}
</Button>
<Col xs={24} xl={4}>
<div className="dashboard-task-action">
<Button
type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)}
>
{item.status === 3 ? '查看纪要' : '监控详情'}
</Button>
</div>
</Col>
</Row>
@ -213,11 +249,44 @@ export const Dashboard: React.FC = () => {
)}
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/>
</Card>
</div>
<AppPagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
onChange={handlePageChange}
/>
</Card>
<style>{`
.dashboard-task-card .ant-card-head,
.dashboard-task-card .ant-card-body,
.dashboard-task-card .ant-list,
.dashboard-task-card .ant-list-items,
.dashboard-task-card .ant-list-item {
min-width: 0;
}
.dashboard-task-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
overscroll-behavior: contain;
}
.dashboard-task-action {
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
}
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
@media (max-width: 1199px) {
.dashboard-task-action {
justify-content: flex-start;
}
}
`}</style>
</PageContainer>
);