ai-chatbot/components/pascal-editor/pascal-editor-frame.tsx
2026-07-08 15:23:05 +08:00

243 lines
8.8 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 {
AlertTriangle,
ChevronDown,
ExternalLink,
LogOut,
RefreshCw,
UserRound,
} from "lucide-react";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { signOut } from "next-auth/react";
import { useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { guestRegex } from "@/lib/constants";
import { cn } from "@/lib/utils";
type PascalEditorFrameProps = {
fallbackSrc: string;
isReady: boolean;
selfHostedSrc: string;
siteLogo?: null | string;
siteLogoDark?: null | string;
siteName?: null | string;
src: string;
sourceLabel: string;
user?: null | User;
};
export function PascalEditorFrame({
fallbackSrc,
isReady,
selfHostedSrc,
siteLogo,
siteLogoDark,
siteName,
src,
sourceLabel,
user,
}: PascalEditorFrameProps) {
const router = useRouter();
const [frameKey, setFrameKey] = useState(0);
const [loaded, setLoaded] = useState(false);
const resolvedSrc = useMemo(() => src || fallbackSrc, [fallbackSrc, src]);
const resolvedSiteName = useMemo(
() => String(siteName || "AI Chatbot").trim() || "AI Chatbot",
[siteName]
);
const displayLogo = useMemo(() => {
const darkLogo = String(siteLogoDark || "").trim();
const lightLogo = String(siteLogo || "").trim();
return darkLogo || lightLogo;
}, [siteLogo, siteLogoDark]);
const userEmail = String(user?.email || "").trim();
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
const isGuest = guestRegex.test(userEmail);
const handleAuthAction = async () => {
if (isGuest) {
router.push("/login?callbackUrl=%2Fpascal-editor");
return;
}
try {
await fetch("/api/auth/logout", { method: "POST" });
await signOut({ callbackUrl: "/login", redirect: false });
window.location.replace("/login");
} catch (error) {
console.error("退出登录失败:", error);
window.location.replace("/login");
}
};
const header = (
<div className="flex h-11 shrink-0 items-center justify-between border-white/10 border-b bg-zinc-950 px-3 text-white">
<a
aria-label={`返回首页 - ${resolvedSiteName}`}
className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"
href="/"
>
{displayLogo ? (
<img
alt={resolvedSiteName}
className="h-7 w-7 shrink-0 object-contain"
src={displayLogo}
/>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white text-center font-semibold text-sm text-zinc-950">
{Array.from(resolvedSiteName)[0] || "A"}
</span>
)}
<span className="min-w-0 truncate font-medium text-sm">
{resolvedSiteName}
</span>
<span className="hidden h-4 w-px shrink-0 bg-white/15 sm:block" />
<span className="hidden shrink-0 text-xs text-zinc-400 sm:block">
建筑编辑
</span>
</a>
<div className="flex shrink-0 items-center gap-2">
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-8 max-w-[180px] items-center gap-2 rounded-md border border-white/10 bg-white/5 px-2 text-white transition-colors hover:bg-white/10"
title={isGuest ? "访客用户" : userLabel}
type="button"
>
{userEmail ? (
<img
alt={userLabel}
className="h-5 w-5 shrink-0 rounded-full"
src={`https://avatar.vercel.sh/${encodeURIComponent(userEmail)}`}
/>
) : (
<UserRound className="h-4 w-4 shrink-0" />
)}
<span className="hidden min-w-0 truncate text-xs sm:block">
{isGuest ? "访客用户" : userLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="min-w-0">
<div className="truncate font-medium text-sm">
{isGuest ? "访客用户" : userLabel}
</div>
{userEmail ? (
<div className="truncate font-normal text-muted-foreground text-xs">
{userEmail}
</div>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(event) => {
event.preventDefault();
void handleAuthAction();
}}
>
<LogOut className="h-4 w-4" />
{isGuest ? "登录正式账号" : "退出登录"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<button
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/5 text-white transition-colors hover:bg-white/10"
onClick={() => {
setLoaded(false);
setFrameKey((key) => key + 1);
}}
title="重新加载"
type="button"
>
<RefreshCw className="h-4 w-4" />
</button>
<a
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/5 text-white transition-colors hover:bg-white/10"
href="/pascal-editor"
rel="noopener noreferrer"
target="_blank"
title="新窗口打开工作区"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
);
if (!isReady) {
return (
<div className="flex h-svh w-full flex-col overflow-hidden bg-zinc-950 text-white">
{header}
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-lg border border-white/10 bg-zinc-900 p-6 shadow-2xl">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-300" />
<div className="min-w-0">
<h1 className="font-semibold text-lg">Pascal Editor 尚未配置</h1>
<p className="mt-2 text-sm text-zinc-300 leading-6">
当前页面已经切换为自托管模式。请启动本机 Pascal standalone
服务,或把构建产物放到
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
public/vendor/pascal-editor/index.html
</code>
,或设置
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
NEXT_PUBLIC_PASCAL_EDITOR_URL
</code>
指向自托管 Pascal 服务地址。
</p>
<div className="mt-4 rounded-md border border-white/10 bg-black/30 p-3 font-mono text-xs text-zinc-300">
NEXT_PUBLIC_PASCAL_EDITOR_URL={selfHostedSrc}
</div>
<p className="mt-3 text-xs text-zinc-500">
默认本机服务:{selfHostedSrc};同源静态 bundle:{fallbackSrc}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-svh w-full flex-col overflow-hidden bg-zinc-950">
{header}
<div className="relative min-h-0 flex-1">
{!loaded && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-zinc-950 text-zinc-300">
<div className="flex items-center gap-3 rounded-lg border border-white/10 bg-zinc-900/90 px-4 py-3 shadow-xl">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="text-sm">正在加载 {sourceLabel}</span>
</div>
</div>
)}
<iframe
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
className="h-full w-full border-0"
key={frameKey}
onLoad={() => setLoaded(true)}
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
src={resolvedSrc}
title="Pascal Editor"
/>
</div>
</div>
);
}