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", "")) ? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", ""))
: ""; : "";
if (taskId == null || taskId.isBlank()) { 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 {
updateProgress(meeting.getId(), 5, "Resuming ASR polling...", 0); 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); this.updateById(taskRecord);
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId); 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); Thread.sleep(2000);
String queryResp = get(queryUrl, asrModel.getApiKey()); String queryResp = get(queryUrl, asrModel.getApiKey());
JsonNode statusNode = objectMapper.readTree(queryResp); 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"); JsonNode data = statusNode.path("data");
String status = data.path("status").asText(); String status = data.path("status").asText();
@ -325,6 +322,58 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return req; 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) @Transactional(rollbackFor = Exception.class)
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) { 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 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 { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import { import {
HistoryOutlined, HistoryOutlined,
@ -16,8 +17,8 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard'; import { getDashboardStats, DashboardStats } from '@/api/business/dashboard';
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting'; import { MeetingVO, getMeetingPage, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -33,12 +34,12 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
if (res.data?.data) { if (res.data?.data) {
setProgress(res.data.data); setProgress(res.data.data);
} }
} catch (err) { } catch {
// ignore // ignore
} }
}; };
fetchProgress(); void fetchProgress();
const timer = setInterval(fetchProgress, 3000); const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [meeting.id, meeting.status]); }, [meeting.id, meeting.status]);
@ -50,7 +51,7 @@ const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) =
return ( 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={{ 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 }}> <Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} /> <LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'} {progress?.message || '准备分析中...'}
@ -73,33 +74,62 @@ export const Dashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]); const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const processingCount = Number(stats?.processingTasks || 0); const processingCount = Number(stats?.processingTasks || 0);
const dashboardLoading = loading && processingCount > 0; const dashboardLoading = loading;
useEffect(() => { useEffect(() => {
fetchDashboardData(); void fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000); }, [pagination.current, pagination.pageSize]);
return () => clearInterval(timer);
}, []);
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 { 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); setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []); setRecentTasks(tasksRes.data?.data?.records || []);
setPagination((prev) => ({
...prev,
total: tasksRes.data?.data?.total || 0,
}));
} catch (err) { } catch (err) {
console.error('Dashboard data load failed', err); console.error('Dashboard data load failed', err);
} finally { } finally {
if (!silent) {
setLoading(false); setLoading(false);
} }
}
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize,
}));
}; };
const renderTaskProgress = (item: MeetingVO) => { const renderTaskProgress = (item: MeetingVO) => {
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status); const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return ( return (
<div style={{ width: '100%', maxWidth: 450 }}> <div style={{ width: '100%', maxWidth: '100%' }}>
<Steps <Steps
size="small" size="small"
current={currentStep} current={currentStep}
@ -108,7 +138,7 @@ export const Dashboard: React.FC = () => {
{ {
title: '语音转录', title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />, icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中') description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI 转录中' : '排队中')
}, },
{ {
title: '智能总结', title: '智能总结',
@ -139,12 +169,13 @@ export const Dashboard: React.FC = () => {
return ( return (
<PageContainer <PageContainer
title="仪表" title="仪表"
subtitle="系统运行概览与最近任务动态" subtitle="系统运行概览与最近任务动态"
style={{ overflow: 'hidden' }}
> >
<Row gutter={24} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16, flexShrink: 0 }}>
{statCards.map((s, idx) => ( {statCards.map((s, idx) => (
<Col span={6} key={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)' }}> <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 <Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>} title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
@ -158,44 +189,48 @@ export const Dashboard: React.FC = () => {
</Row> </Row>
<Card <Card
className="dashboard-task-card"
title={ title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<Space><ClockCircleOutlined /> </Space> <Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button> <Button type="link" onClick={() => navigate('/meetings')}></Button>
</div> </div>
} }
variant="borderless" variant="borderless"
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} 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 <List
loading={dashboardLoading} loading={dashboardLoading}
dataSource={recentTasks} dataSource={recentTasks}
renderItem={(item) => ( renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}> <List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<Row gutter={32} align="middle"> <Row gutter={[24, 16]} align="middle">
<Col span={8}> <Col xs={24} xl={8}>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}> <Title level={5} style={{ margin: 0, cursor: 'pointer', wordBreak: 'break-word' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title} {item.title}
</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"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text> <Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
</Space> </Space>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{item.tags?.split(',').filter(Boolean).map((t) => ( {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> </div>
</Space> </Space>
</Col> </Col>
<Col span={12}> <Col xs={24} xl={12}>
{renderTaskProgress(item)} {renderTaskProgress(item)}
</Col> </Col>
<Col span={4} style={{ textAlign: 'right' }}> <Col xs={24} xl={4}>
<div className="dashboard-task-action">
<Button <Button
type={item.status === 3 ? 'primary' : 'default'} type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3} ghost={item.status === 3}
@ -204,6 +239,7 @@ export const Dashboard: React.FC = () => {
> >
{item.status === 3 ? '查看纪要' : '监控详情'} {item.status === 3 ? '查看纪要' : '监控详情'}
</Button> </Button>
</div>
</Col> </Col>
</Row> </Row>
@ -213,11 +249,44 @@ export const Dashboard: React.FC = () => {
)} )}
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }} locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/> />
</div>
<AppPagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
onChange={handlePageChange}
/>
</Card> </Card>
<style>{` <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-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; } .ant-steps-item-description { font-size: 11px !important; }
@media (max-width: 1199px) {
.dashboard-task-action {
justify-content: flex-start;
}
}
`}</style> `}</style>
</PageContainer> </PageContainer>
); );