Files
wordcloud/frontend/src/components/ProgressPanel.tsx
T

116 lines
4.4 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { SSEProgress } from '../types';
import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons';
interface ProgressPanelProps {
progress: SSEProgress | null;
logLines: string[];
visible: boolean;
}
export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) {
const logEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll the log view to the bottom whenever new lines arrive.
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [logLines]);
// All hooks MUST run before any early return, so derive the values the
// callback needs without depending on `progress` being non-null.
const message = progress?.message ?? '';
const handleCopyError = useCallback(() => {
const text = logLines.length > 0 ? logLines.join('\n') : message;
navigator.clipboard.writeText(text).catch(() => {
const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null;
if (el) { el.select(); document.execCommand('copy'); }
});
}, [logLines, message]);
if (!visible || !progress) return null;
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
const isDone = progress.stage === '完成';
return (
<div className="progress-panel" style={isFailed ? { borderColor: 'var(--danger)' } : {}}>
<div className="progress-title">
{isFailed ? <><IconCross /> 生成失败</> : isDone ? <><IconCheckmark /> 生成完成</> : <><IconGear /> 词云生成中…</>}
</div>
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
{progress.stage}
</div>
{(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && (
<div className="progress-timing" style={{ color: isDone ? 'var(--success)' : 'var(--text-muted)' }}>
{progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)} 秒`}
{progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''}
{progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)} 秒`}
</div>
)}
{!isFailed && (
<div className="progress-bar-track">
<div
className="progress-bar-fill"
style={{
width: `${Math.max(0, Math.min(100, progress.percent))}%`,
background: isDone ? 'var(--success)' : 'var(--accent)',
}}
/>
</div>
)}
{/* ── 详细日志(实时滚动) ───────────────────────────────── */}
{logLines.length > 0 && (
<div
className="progress-log-view"
style={{
marginTop: 8,
maxHeight: 320,
overflowY: 'auto',
background: 'var(--bg-secondary, #1a1a2e)',
borderRadius: 6,
padding: '8px 10px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--text-muted, #888)',
border: '1px solid var(--border-color, #333)',
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
<div ref={logEndRef} />
</div>
)}
{/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
<div
className="progress-message"
style={{
color: isFailed ? 'var(--danger)' : 'var(--text-muted)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: 1.5,
marginTop: isFailed ? 8 : 0,
}}
>
{isFailed ? (
<div className="error-detail-container">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleCopyError}
title="复制完整日志"
style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<IconCopy /> 复制完整日志
</button>
</div>
) : null}
</div>
</div>
);
}