453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
"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=skill,URL 已更新");
|
||
}
|
||
|
||
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>
|
||
|
||
{/* 场景 2:skill 选择通信 */}
|
||
<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>
|
||
|
||
{/* 场景 5:is_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>
|
||
);
|
||
}
|