deerflow2/frontend/src/components/workspace/iframe-test-panel.tsx

453 lines
15 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.

"use client";
import { useSearchParams, useRouter } from "next/navigation";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Button } from "@/components/ui/button";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { copyToClipboard } from "@/lib/utils";
import { cn } from "@/lib/utils";
/**
* IframeTestPanel —— 仅用于开发阶段测试 iframe 通信功能
*
* 测试场景:
* 1. mode=skill 侧边栏隐藏
* 2. useSpecificChatMode 注入提示词
* 3. sendSelectSkill / openSkillDialog / clearSkill
*/
export function IframeTestPanel() {
const router = useRouter();
const searchParams = useSearchParams();
const iframeSkill = useIframeSkill();
const [log, setLog] = useState<string[]>([]);
const [open, setOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const [dragging, setDragging] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const panelSizeRef = useRef({ width: 0, height: 0 });
const isSkillMode = searchParams.get("mode") === "skill";
function addLog(msg: string) {
setLog((prev) => [
`[${new Date().toLocaleTimeString()}] ${msg}`,
...prev.slice(0, 9),
]);
}
function handleEnterSkillMode() {
router.push(`?mode=skill`);
addLog("进入 mode=skillURL 已更新");
}
function handleExitSkillMode() {
router.push(`?`);
addLog("退出 skill 模式");
}
function handleSendSelectSkill() {
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
addLog(
"postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])",
);
}
function handleSendSelectSkillArray() {
iframeSkill.sendSelectSkill([
{ id: "1246", name: "技能A" },
{ id: "1247", name: "技能B" },
{ id: "1248", name: "技能C" },
]);
addLog("postMessage → selectedSkills (3 skills)");
}
function handleOpenSkillDialog() {
iframeSkill.openSkillDialog();
addLog("postMessage → openSkillDialog");
}
function handleClearSkill() {
iframeSkill.clearSkill();
addLog("clearSkill 已调用postMessage → selectedSkills=[]");
}
function handleTestClipboardCopy() {
const testText = "测试复制内容 - " + new Date().toISOString();
void copyToClipboard(testText);
addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`);
}
function handleSendIsChatting(isChatting: boolean) {
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: isChatting,
});
addLog(`postMessage → is_chatting (${isChatting})`);
}
function handlePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
panelSizeRef.current = { width: rect.width, height: rect.height };
dragOffsetRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setPosition({ x: rect.left, y: rect.top });
setDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}
useEffect(() => {
if (!dragging) return;
const handleMove = (event: PointerEvent) => {
const { width, height } = panelSizeRef.current;
const nextX = event.clientX - dragOffsetRef.current.x;
const nextY = event.clientY - dragOffsetRef.current.y;
const clampedX = Math.min(
Math.max(8, nextX),
Math.max(8, window.innerWidth - width - 8),
);
const clampedY = Math.min(
Math.max(8, nextY),
Math.max(8, window.innerHeight - height - 8),
);
setPosition({ x: clampedX, y: clampedY });
};
const handleUp = () => {
setDragging(false);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
return () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
}, [dragging]);
// 检测是否在 iframe 中
const isInIframe =
typeof window !== "undefined" && window.self !== window.top;
if (!open) {
return (
<button
className={cn(
"fixed z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600",
position ? "top-0 left-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
onClick={() => setOpen(true)}
>
🧪
</button>
);
}
return (
<div
ref={panelRef}
className={cn(
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm",
position ? "top-0 left-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
>
{/* 标题栏 */}
<div
className={cn(
"flex cursor-grab items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2 select-none",
dragging && "cursor-grabbing",
)}
onPointerDown={handlePointerDown}
>
<span className="text-xs font-bold text-white">🧪 iframe </span>
<div className="flex items-center gap-2">
<button
className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? "▢" : "—"}
</button>
<button
className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setOpen(false)}
>
</button>
</div>
</div>
{!collapsed && (
<div className="space-y-3 p-3">
{/* 当前状态 */}
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
<div className="mb-1 font-semibold text-gray-500"></div>
<div className="flex flex-col gap-1">
<span>
<span className="text-gray-400">mode</span>
<span
className={cn(
"font-mono font-bold",
isSkillMode ? "text-violet-600" : "text-gray-400",
)}
>
{isSkillMode ? "skill ✅" : "普通"}
</span>
</span>
<span>
<span className="text-gray-400">selectedSkill</span>
<span className="font-mono text-violet-600">
{iframeSkill.selectedSkill
? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}`
: "无"}
</span>
</span>
</div>
</div>
{/* 场景 1侧边栏隐藏 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
layout
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 text-xs"
variant="outline"
onClick={handleEnterSkillMode}
>
skill
</Button>
<Button
size="sm"
className="flex-1 text-xs"
variant="outline"
onClick={handleExitSkillMode}
>
退 skill
</Button>
</div>
</div>
{/* 场景 2skill 选择通信 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
postMessage 宿
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleSendSelectSkill}
>
sendSelectSkill
</Button>
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleSendSelectSkillArray}
>
sendSelectSkill
</Button>
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleOpenSkillDialog}
>
openSkillDialog
</Button>
<Button
size="sm"
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
variant="ghost"
onClick={handleClearSkill}
>
clearSkill ( skill_id=[])
</Button>
</div>
</div>
{/* 场景 3接收宿主页 selectedSkill */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
宿 selectedSkill
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
variant="ghost"
onClick={() => {
window.postMessage(
{ type: "selectedSkill", id: 5, title: "文档处理" },
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
);
}}
>
selectedSkill
</Button>
<Button
size="sm"
className="w-full bg-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: [
{ id: "5", name: "文档处理" },
{ id: "1216", name: "市场研究报告" },
{ id: "1245", name: "市场研究报告" },
{ id: "520", name: "市场研究报告" },
{ id: "409", name: "市场研究报告" },
],
},
"*",
);
addLog(
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
);
}}
>
📦 selectedSkills message
</Button>
<Button
size="sm"
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: [],
},
"*",
);
addLog("模拟宿主页 → selectedSkills []");
}}
>
🧹 selectedSkills
</Button>
<Button
size="sm"
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: "selectedSkill",
id: 999999,
title: "不存在的技能",
},
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
);
}}
>
selectedSkill/
</Button>
</div>
</div>
{/* 场景 4剪贴板复制iframe 通信) */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500">
iframe
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium",
isInIframe
? "bg-violet-100 text-violet-700"
: "bg-gray-100 text-gray-500",
)}
>
{isInIframe ? "iframe 模式" : "独立页面"}
</span>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
variant="ghost"
onClick={handleTestClipboardCopy}
>
📋
</Button>
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
{isInIframe
? "将通过 postMessage 请求父页面复制"
: "将直接调用 navigator.clipboard"}
</div>
</div>
</div>
{/* 场景 5is_chatting */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
is_chatting
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
variant="ghost"
onClick={() => handleSendIsChatting(true)}
>
true
</Button>
<Button
size="sm"
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
variant="ghost"
onClick={() => handleSendIsChatting(false)}
>
false
</Button>
</div>
</div>
{/* 日志 */}
{log.length > 0 && (
<div className="rounded-lg bg-gray-900 p-2">
<div className="mb-1 text-[10px] font-semibold text-gray-400">
</div>
{log.map((l, i) => (
<div
key={i}
className="truncate font-mono text-[10px] text-green-400"
>
{l}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}