图片放大(ImageLightbox)与工具详情(ToolDetailModal)渲染在虚拟化消息行内,行元素的 transform 成为 fixed 后代的包含块,导致遮罩只覆盖行区域、工具栏定位偏移、其他内容叠在其上。 改用 createPortal 渲染到 document.body,并将 z-index 提升到 z-[10000] 超过下拉浮层 z-[9999],恢复视口级覆盖与右上角按钮定位。
156 lines
5.7 KiB
TypeScript
156 lines
5.7 KiB
TypeScript
import { useEffect } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
|
||
// 模块级常量:保持引用稳定,避免每次渲染重建数组
|
||
const REMARK_PLUGINS = [remarkGfm];
|
||
|
||
interface ToolDetailModalProps {
|
||
toolName: string;
|
||
status: string;
|
||
statusLabel: string;
|
||
arguments?: unknown;
|
||
resultContent: string;
|
||
callContent: string;
|
||
durationMs?: number;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function formatDuration(ms: number): string {
|
||
if (ms < 1000) return `${ms}ms`;
|
||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||
const minutes = Math.floor(ms / 60000);
|
||
const seconds = Math.floor((ms % 60000) / 1000);
|
||
return `${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
function formatJSON(text: string): string {
|
||
if (!text) return '';
|
||
try {
|
||
const parsed = JSON.parse(text);
|
||
return JSON.stringify(parsed, null, 2);
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
export function ToolDetailModal({
|
||
toolName,
|
||
status,
|
||
statusLabel,
|
||
arguments: args,
|
||
resultContent,
|
||
callContent,
|
||
durationMs,
|
||
onClose,
|
||
}: ToolDetailModalProps) {
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||
}, [onClose]);
|
||
|
||
const displayContent = resultContent || callContent;
|
||
const formattedContent = formatJSON(displayContent);
|
||
|
||
const statusColor =
|
||
status === 'calling'
|
||
? 'var(--accent-amber)'
|
||
: status === 'result'
|
||
? 'var(--accent-green)'
|
||
: status === 'pending'
|
||
? 'var(--accent-amber)'
|
||
: 'var(--text-muted)';
|
||
|
||
// portal 到 body:在虚拟化消息行内打开时,行元素的 transform 会成为
|
||
// fixed 后代的包含块,遮罩/定位全部失效(同 ImageLightbox)。
|
||
// z-[10000]:高于下拉选择器(z-[9999])等全局浮层。
|
||
return createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
||
onClick={onClose}
|
||
>
|
||
{/* Modal container */}
|
||
<div
|
||
className="relative w-[90vw] max-w-4xl max-h-[90vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl flex flex-col overflow-hidden animate-scale-in"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
||
<div className="flex items-center gap-2.5">
|
||
<Terminal className="h-5 w-5 text-[var(--accent-cyan)]" />
|
||
<span className="text-lg font-semibold text-[var(--text-primary)]">{toolName}</span>
|
||
</div>
|
||
<span
|
||
className="px-2.5 py-1 rounded-full text-sm font-medium"
|
||
style={{
|
||
backgroundColor: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
|
||
color: statusColor,
|
||
border: `1px solid color-mix(in srgb, ${statusColor} 25%, transparent)`,
|
||
}}
|
||
>
|
||
{statusLabel}
|
||
</span>
|
||
{durationMs != null && (
|
||
<span className="flex items-center gap-1.5 text-sm text-[var(--text-muted)]">
|
||
<Clock className="h-4 w-4" />
|
||
{formatDuration(durationMs)}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||
aria-label="关闭"
|
||
title="关闭 (Esc)"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body — scrollable */}
|
||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||
{/* Arguments */}
|
||
{args !== undefined && args !== null && (
|
||
<div>
|
||
<div className="text-base font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||
<span className="w-1 h-5 rounded-full bg-[var(--accent-cyan)] inline-block" />
|
||
参数
|
||
</div>
|
||
<pre className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)]">
|
||
{JSON.stringify(args, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{/* Result / Output */}
|
||
{displayContent && (
|
||
<div>
|
||
<div className="text-base font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||
<span className="w-1 h-5 rounded-full bg-[var(--accent-green)] inline-block" />
|
||
{resultContent ? '结果' : '输出'}
|
||
</div>
|
||
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
|
||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{formattedContent}</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center justify-between text-sm text-[var(--text-muted)]">
|
||
<span>按 Esc 关闭</span>
|
||
<span className="flex items-center gap-1">
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
放大视图
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|