refactor: 优化会议创建按钮逻辑和生成进度显示

- 简化 `Meetings.tsx` 中的会议创建按钮逻辑
- 在 `MeetingDetail.tsx` 中添加 `MeetingStateNotice` 类型,并更新生成进度显示
- 优化生成失败提示和展示逻辑,增加对历史内容的支持
- 更新相关组件以支持新的生成进度和状态显示
dev_na
chenhao 2026-05-14 09:20:11 +08:00
parent 7d08234919
commit 7989b6aa11
2 changed files with 220 additions and 66 deletions

View File

@ -96,6 +96,14 @@ type ChapterTranscriptLink = {
firstTranscriptStartTime: number | null; firstTranscriptStartTime: number | null;
}; };
type MeetingStateNotice = {
title: string;
description: string;
type: 'info' | 'warning';
hasFallbackContent: boolean;
scope: 'summary' | 'catalog' | 'global';
};
const ANALYSIS_EMPTY: MeetingAnalysis = { const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: '', overview: '',
keywords: [], keywords: [],
@ -355,8 +363,10 @@ const MeetingProgressDisplay: React.FC<{
meetingId: number; meetingId: number;
onComplete: () => void; onComplete: () => void;
onProgressUpdate?: (meeting: MeetingVO) => void; onProgressUpdate?: (meeting: MeetingVO) => void;
onProgressChange?: (progress: MeetingProgress | null) => void;
compact?: boolean; compact?: boolean;
}> = ({ meetingId, onComplete, onProgressUpdate, compact }) => { inline?: boolean;
}> = ({ meetingId, onComplete, onProgressUpdate, onProgressChange, compact, inline }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null); const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => { useEffect(() => {
@ -382,8 +392,10 @@ const MeetingProgressDisplay: React.FC<{
} }
if (progressRes.data?.data) { if (progressRes.data?.data) {
setProgress(progressRes.data.data); const nextProgress = progressRes.data.data;
if (progressRes.data.data.percent === 100 || progressRes.data.data.percent < 0) { setProgress(nextProgress);
onProgressChange?.(nextProgress);
if (nextProgress.percent === 100 || nextProgress.percent < 0) {
completed = true; completed = true;
onComplete(); onComplete();
} }
@ -399,7 +411,7 @@ const MeetingProgressDisplay: React.FC<{
completed = true; completed = true;
clearInterval(timer); clearInterval(timer);
}; };
}, [meetingId, onComplete, onProgressUpdate]); }, [meetingId, onComplete, onProgressChange, onProgressUpdate]);
const percent = progress?.percent || 0; const percent = progress?.percent || 0;
const isError = percent < 0; const isError = percent < 0;
@ -412,6 +424,48 @@ const MeetingProgressDisplay: React.FC<{
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`; return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
}; };
if (inline) {
return (
<div
style={{
marginTop: 12,
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
background: isError ? '#fff2f0' : '#f7f8ff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 8,
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{progress?.message || '正在生成新版总结...'}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
</Text>
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
size="small"
style={{ marginBottom: 6 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
);
}
if (compact) { if (compact) {
return ( return (
<div <div
@ -778,6 +832,8 @@ const MeetingDetail: React.FC = () => {
const transcriptSectionRef = useRef<HTMLDivElement>(null); const transcriptSectionRef = useRef<HTMLDivElement>(null);
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false); const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null); const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
const [generationProgress, setGenerationProgress] = useState<MeetingProgress | null>(null);
const autoOpenedCatalogAttemptRef = useRef<string | null>(null);
const fetchData = useCallback(async (meetingId: number) => { const fetchData = useCallback(async (meetingId: number) => {
try { try {
@ -917,12 +973,49 @@ const MeetingDetail: React.FC = () => {
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2; const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
const generationFailureNotice = useMemo(() => { const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
const hasCatalogContent = catalogChapterLinks.length > 0;
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || meeting.status !== 4) { if (!meeting || meeting.status !== 4) {
return null; return null;
} }
const hasFallbackContent = Boolean(meeting.summaryContent) || meetingChapters.length > 0; const hasFallbackContent = hasSummaryContent || hasCatalogContent;
if (meeting.latestChapterAttemptStatus === 3) {
const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败';
return {
title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败',
description: hasFallbackContent
? `最近一次 AI 目录生成失败,当前展示的是最近一次成功生成的内容。失败原因:${detail}`
: `AI 目录生成失败,当前没有可展示的历史目录或总结内容。失败原因:${detail}`,
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'catalog',
};
}
if (meeting.latestSummaryAttemptStatus === 3) {
const detail = meeting.latestSummaryAttemptErrorMsg || '总结生成失败';
return {
title: hasFallbackContent ? '历史总结可用' : '本次总结生成失败',
description: hasFallbackContent
? `最近一次重新总结失败,当前展示的是最近一次成功生成的总结内容。失败原因:${detail}`
: `总结生成失败,当前没有可展示的历史总结内容。失败原因:${detail}`,
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'summary',
};
}
return {
title: hasFallbackContent ? '历史内容仍可查看' : '会议处理异常',
description: hasFallbackContent
? '最近一次处理未成功,当前展示的是最近一次成功生成的内容。你可以继续查看,或重新发起识别/总结。'
: '会议在处理中遇到问题,暂时没有可展示的总结内容。你可以重新发起识别或总结。',
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'global',
};
if (meeting.latestChapterAttemptStatus === 3) { if (meeting.latestChapterAttemptStatus === 3) {
const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败'; const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败';
return { return {
@ -953,7 +1046,37 @@ const MeetingDetail: React.FC = () => {
description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。', description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
hasFallbackContent, hasFallbackContent,
}; };
}, [meeting, meetingChapters.length]); }, [hasCatalogContent, hasSummaryContent, meeting]);
const summaryPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || !hasSummaryContent) {
return null;
}
if (meeting.status === 2) {
return {
title: '正在生成新版总结',
description: '当前展示的是上一版已成功生成的总结,系统会在新版完成后自动替换。',
type: 'info',
hasFallbackContent: true,
scope: 'summary',
};
}
return null;
}, [generationFailureNotice, hasSummaryContent, meeting]);
const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!generationFailureNotice || generationFailureNotice.scope !== 'catalog') {
return null;
}
if (generationFailureNotice.hasFallbackContent && hasCatalogContent) {
return {
title: '当前展示的是历史成功目录',
description: generationFailureNotice.description,
type: generationFailureNotice.type,
hasFallbackContent: true,
scope: 'catalog',
};
}
return generationFailureNotice;
}, [generationFailureNotice, hasCatalogContent]);
const emptyTranscriptFailureNotice = useMemo(() => { const emptyTranscriptFailureNotice = useMemo(() => {
if (!meeting || meeting.status !== 4 || transcripts.length > 0) { if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
return null; return null;
@ -968,6 +1091,34 @@ const MeetingDetail: React.FC = () => {
}; };
}, [canRetryTranscription, meeting, transcripts.length]); }, [canRetryTranscription, meeting, transcripts.length]);
useEffect(() => {
if (meeting?.status !== 1 && meeting?.status !== 2) {
setGenerationProgress(null);
autoOpenedCatalogAttemptRef.current = null;
}
}, [meeting?.id, meeting?.status]);
useEffect(() => {
const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? '');
if (meeting?.status !== 2 || !attemptKey) {
return;
}
if ((generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) {
return;
}
if (autoOpenedCatalogAttemptRef.current === attemptKey) {
return;
}
autoOpenedCatalogAttemptRef.current = attemptKey;
setWorkspaceTab('catalog');
}, [
catalogChapterLinks.length,
generationProgress?.percent,
meeting?.latestChapterAttemptTaskId,
meeting?.latestSummaryAttemptTaskId,
meeting?.status,
]);
useEffect(() => { useEffect(() => {
if (!playbackAudioUrl) { if (!playbackAudioUrl) {
setShowFloatingTranscriptPlayer(false); setShowFloatingTranscriptPlayer(false);
@ -1778,7 +1929,7 @@ const MeetingDetail: React.FC = () => {
</Button> </Button>
)} )}
{(playbackAudioUrl || transcripts.length > 0 || (meeting.status === 3 && !!meeting.summaryContent)) && ( {(playbackAudioUrl || transcripts.length > 0 || hasSummaryContent) && (
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
@ -1795,7 +1946,7 @@ const MeetingDetail: React.FC = () => {
onClick: handleDownloadTranscript, onClick: handleDownloadTranscript,
disabled: downloadLoading === 'transcript', disabled: downloadLoading === 'transcript',
}] : []), }] : []),
...(meeting.status === 3 && !!meeting.summaryContent ? [ ...(hasSummaryContent ? [
{ {
key: 'pdf', key: 'pdf',
label: '下载 PDF', label: '下载 PDF',
@ -1848,14 +1999,16 @@ const MeetingDetail: React.FC = () => {
void fetchData(updated.id); void fetchData(updated.id);
} }
}} }}
onProgressChange={setGenerationProgress}
/> />
) : ( ) : (
<Row gutter={24} style={{ height: '100%' }}> <>
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} xl={13} style={{ height: '100%' }}> <Col xs={24} xl={13} style={{ height: '100%' }}>
<div className="detail-side-column detail-left-column"> <div className="detail-side-column detail-left-column">
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && ( {(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
<Alert <Alert
type="warning" type={generationFailureNotice?.type || 'warning'}
showIcon showIcon
style={{ marginBottom: 16, borderRadius: 12 }} style={{ marginBottom: 16, borderRadius: 12 }}
message={ message={
@ -1883,21 +2036,48 @@ const MeetingDetail: React.FC = () => {
)} )}
<Card className="left-flow-card summary-panel" variant="borderless"> <Card className="left-flow-card summary-panel" variant="borderless">
{meeting.status === 2 ? ( {meeting.status === 2 && !hasSummaryContent ? (
<div className="summary-progress-shell"> <div className="summary-progress-shell">
<MeetingProgressDisplay <MeetingProgressDisplay
meetingId={meeting.id} meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)} onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => { onProgressUpdate={(updated) => {
if (updated.status !== meeting.status) { if (updated.status === 2 || updated.status !== meeting.status) {
void fetchData(updated.id); void fetchData(updated.id);
} }
}} }}
onProgressChange={setGenerationProgress}
compact compact
/> />
</div> </div>
) : meeting.summaryContent ? ( ) : hasSummaryContent ? (
<div className="summary-content-box"> <div className="summary-content-box">
{summaryPanelNotice && (
<Alert
type={summaryPanelNotice.type}
showIcon
style={{ marginBottom: 20, borderRadius: 14 }}
message={summaryPanelNotice.title}
description={
<div>
<div>{summaryPanelNotice.description}</div>
{meeting.status === 2 ? (
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => {
if (updated.status === 2 || updated.status !== meeting.status) {
void fetchData(updated.id);
}
}}
onProgressChange={setGenerationProgress}
inline
/>
) : null}
</div>
}
/>
)}
{/* Keywords placed between title and overview */} {/* Keywords placed between title and overview */}
<div className="summary-section" style={{ marginBottom: 24 }}> <div className="summary-section" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
@ -2078,13 +2258,13 @@ const MeetingDetail: React.FC = () => {
<div className="transcript-scroll-shell"> <div className="transcript-scroll-shell">
{workspaceTab === 'catalog' ? ( {workspaceTab === 'catalog' ? (
<div className="catalog-list"> <div className="catalog-list">
{generationFailureNotice && !generationFailureNotice.hasFallbackContent && ( {catalogPanelNotice && (
<Alert <Alert
type="warning" type={catalogPanelNotice.type}
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="AI 目录生成失败" message="AI 目录生成失败"
description={generationFailureNotice.description} description={catalogPanelNotice.description}
/> />
)} )}
{catalogChapterLinks.length ? ( {catalogChapterLinks.length ? (
@ -2118,6 +2298,14 @@ const MeetingDetail: React.FC = () => {
</div> </div>
</div> </div>
</div> )) </div> ))
) : meeting.status === 2 ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="AI 目录生成中"
description={generationProgress?.message || '正在生成 AI 目录,章节完成后会自动展示。'}
/>
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" />
)} )}
@ -2169,7 +2357,8 @@ const MeetingDetail: React.FC = () => {
)} )}
</div> </div>
</Col> </Col>
</Row> </Row>
</>
)} )}
</div> </div>

View File

@ -594,54 +594,19 @@ const Meetings: React.FC = () => {
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button> <Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group> </Radio.Group>
{configLoaded && ( {configLoaded && (
<> <Button
{!createConfig.offlineEnabled && !createConfig.realtimeEnabled ? ( type="primary"
<Button type="primary" icon={<PlusOutlined />} disabled> icon={<PlusOutlined />}
disabled={!createConfig.offlineEnabled && !createConfig.realtimeEnabled}
</Button> onClick={() => {
) : createConfig.offlineEnabled && createConfig.realtimeEnabled ? ( if (createConfig.offlineEnabled || createConfig.realtimeEnabled) {
<Dropdown setCreateDrawerType(createConfig.offlineEnabled ? "upload" : "realtime");
menu={{ setCreateDrawerVisible(true);
items: [ }
{ }}
key: "upload", >
icon: <CloudUploadOutlined />,
label: "上传录音", </Button>
onClick: () => {
setCreateDrawerType("upload");
setCreateDrawerVisible(true);
},
},
{
key: "realtime",
icon: <AudioOutlined />,
label: "实时会议",
onClick: () => {
setCreateDrawerType("realtime");
setCreateDrawerVisible(true);
},
},
],
}}
placement="bottomRight"
>
<Button type="primary" icon={<PlusOutlined />}>
</Button>
</Dropdown>
) : (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateDrawerType(createConfig.offlineEnabled ? "upload" : "realtime");
setCreateDrawerVisible(true);
}}
>
{createConfig.offlineEnabled ? "上传录音" : "实时会议"}
</Button>
)}
</>
)} )}
</Space> </Space>
} }