修复嵌入双按钮问题

This commit is contained in:
537yaha
2026-05-18 13:19:46 +08:00
parent 505322911e
commit ad0c9fddc9
5 changed files with 80 additions and 22 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ npm run dev
## 集成访客小窗到你的网站(iframe)
把下面代码放到你网站的 `</body>` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`
把下面代码放到你网站的 `</body>` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`
**说明**`/chat` 在 iframe 内会自动进入嵌入模式(也可显式加 `?embed=1`),只显示聊天界面,不会出现第二个浮动按钮;宿主站点保留外层「打开/关闭」按钮即可,**点一次**即可对话。
```html
<div id="ai-cs-widget" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;">
+25 -5
View File
@@ -1,16 +1,23 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { ChatWidget } from "@/components/visitor/ChatWidget";
import { FloatingButton } from "@/components/visitor/FloatingButton";
import { isChatEmbedMode } from "@/lib/chat-embed";
/**
* 访客聊天页面
* 使用小窗插件形式,显示浮动按钮聊天小窗
* - 独立打开:右下角浮动按钮 + 聊天小窗
* - iframe / ?embed=1:直接铺满,供宿主站点一次点击打开 iframe 即可使用
*/
export default function ChatPage() {
const [visitorId, setVisitorId] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const embedded = useSyncExternalStore(
() => () => {},
() => isChatEmbedMode(),
() => false
);
// 初始化访客 ID(使用 localStorage 保持连续性)
useEffect(() => {
@@ -27,19 +34,32 @@ export default function ChatPage() {
setIsOpen((prev) => !prev);
};
const loadingShell = embedded ? "h-[100dvh] w-full" : "min-h-screen";
if (visitorId === null) {
return (
<div className="flex items-center justify-center min-h-screen bg-muted/30 text-muted-foreground">
<div className={`flex items-center justify-center ${loadingShell} bg-muted/30 text-muted-foreground`}>
...
</div>
);
}
if (embedded) {
return (
<div className="h-[100dvh] w-full overflow-hidden bg-white">
<ChatWidget
visitorId={visitorId}
isOpen
embedded
onToggle={() => {}}
/>
</div>
);
}
return (
<>
{/* 浮动按钮 */}
<FloatingButton onClick={handleToggle} isOpen={isOpen} />
{/* 聊天小窗 */}
{isOpen && (
<ChatWidget visitorId={visitorId} isOpen={isOpen} onToggle={handleToggle} />
)}
+32 -15
View File
@@ -45,6 +45,8 @@ import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
interface ChatWidgetProps {
visitorId: number;
isOpen: boolean;
/** iframe 嵌入:铺满容器,不使用 fixed + portal */
embedded?: boolean;
onToggle: () => void;
}
@@ -94,7 +96,12 @@ const CHAT_WIDGET_PANEL_MAX_W =
* 聊天小窗组件
* 提供小窗形式的聊天界面,支持展开/收起
*/
export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
export function ChatWidget({
visitorId,
isOpen,
embedded = false,
onToggle,
}: ChatWidgetProps) {
const { t } = useI18n();
const WEB_SEARCH_PREF_KEY = "visitor_widget_need_web_search";
// 数据分析:每次由关→开上报一次小窗打开(供后台「小窗打开次数」统计)
@@ -710,20 +717,19 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
return null;
}
// 挂到 body,避免页面内祖先的 transform/filter/backdrop-filter 在 Chrome 等浏览器中
// 成为 fixed 定位的包含块,导致小窗错位到视口中上部而右下角按钮仍正常。
if (typeof document === "undefined") {
return null;
}
return createPortal(
const panel = (
<Card
className={cn(
"fixed bottom-20 right-4 sm:bottom-24 sm:right-6 flex flex-col shadow-[0_24px_60px_-24px_rgba(2,6,23,0.35)] z-40 border border-slate-200 overflow-hidden rounded-2xl bg-white text-slate-900 ring-1 ring-slate-200/80",
CHAT_WIDGET_PANEL_MAX_W,
CHAT_WIDGET_PANEL_WIDTH,
CHAT_WIDGET_PANEL_MAX_H,
CHAT_WIDGET_PANEL_H
"flex flex-col overflow-hidden bg-white text-slate-900",
embedded
? "h-full w-full min-h-0 rounded-none border-0 shadow-none ring-0"
: cn(
"fixed bottom-20 right-4 sm:bottom-24 sm:right-6 shadow-[0_24px_60px_-24px_rgba(2,6,23,0.35)] z-40 border border-slate-200 rounded-2xl ring-1 ring-slate-200/80",
CHAT_WIDGET_PANEL_MAX_W,
CHAT_WIDGET_PANEL_WIDTH,
CHAT_WIDGET_PANEL_MAX_H,
CHAT_WIDGET_PANEL_H
)
)}
>
{/* 头部:回归品牌蓝色系,保持轻量与一致 */}
@@ -999,8 +1005,19 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
}
/>
</div>
</Card>,
document.body
</Card>
);
if (embedded) {
return panel;
}
// 挂到 body,避免页面内祖先的 transform/filter/backdrop-filter 在 Chrome 等浏览器中
// 成为 fixed 定位的包含块,导致小窗错位到视口中上部而右下角按钮仍正常。
if (typeof document === "undefined") {
return null;
}
return createPortal(panel, document.body);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* 访客 /chat 是否处于「被宿主 iframe 嵌入」模式。
* 嵌入时不展示页内 FloatingButton,直接铺满 iframe 显示 ChatWidget。
*/
export function isChatEmbedMode(): boolean {
if (typeof window === "undefined") return false;
const q = new URLSearchParams(window.location.search);
if (q.get("embed") === "1" || q.get("embed") === "true") return true;
try {
return window.self !== window.top;
} catch {
// 跨域 iframe 访问 top 会抛错,说明正在被外部站点嵌入
return true;
}
}
+4 -1
View File
@@ -92,7 +92,10 @@
function createChatWindow() {
const iframe = document.createElement('iframe');
iframe.id = 'ai-cs-widget-iframe';
iframe.src = config.chatPageUrl || `${config.apiUrl.replace('/api', '')}/chat`;
const baseChat =
config.chatPageUrl || `${config.apiUrl.replace('/api', '')}/chat`;
const sep = baseChat.includes('?') ? '&' : '?';
iframe.src = `${baseChat}${sep}embed=1`;
iframe.style.cssText = `
position: fixed;
bottom: 5rem;