116 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|