243 lines
8.8 KiB
TypeScript
243 lines
8.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|