PicoBot/web/src/components/Chat/ToolDetailModal.tsx
oudecheng f711948902 fix(chat): 修复图片放大与工具详情浮层被覆盖及定位错乱
图片放大(ImageLightbox)与工具详情(ToolDetailModal)渲染在虚拟化消息行内,行元素的 transform 成为 fixed 后代的包含块,导致遮罩只覆盖行区域、工具栏定位偏移、其他内容叠在其上。

改用 createPortal 渲染到 document.body,并将 z-index 提升到 z-[10000] 超过下拉浮层 z-[9999],恢复视口级覆盖与右上角按钮定位。
2026-08-18 23:00:16 +08:00

156 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
}