diff --git a/README.md b/README.md index 87b5c2a..21162f2 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ npm run dev ## 集成访客小窗到你的网站(iframe) -把下面代码放到你网站的 `` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`: +把下面代码放到你网站的 `` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`。 +**说明**:`/chat` 在 iframe 内会自动进入嵌入模式(也可显式加 `?embed=1`),只显示聊天界面,不会出现第二个浮动按钮;宿主站点保留外层「打开/关闭」按钮即可,**点一次**即可对话。 ```html
diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index 460e9b4..8ec74c9 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -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(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 ( -
+
正在初始化...
); } + if (embedded) { + return ( +
+ {}} + /> +
+ ); + } + return ( <> - {/* 浮动按钮 */} - {/* 聊天小窗 */} {isOpen && ( )} diff --git a/frontend/components/visitor/ChatWidget.tsx b/frontend/components/visitor/ChatWidget.tsx index ce5b003..bbb00f0 100644 --- a/frontend/components/visitor/ChatWidget.tsx +++ b/frontend/components/visitor/ChatWidget.tsx @@ -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 = ( {/* 头部:回归品牌蓝色系,保持轻量与一致 */} @@ -999,8 +1005,19 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) { } />
- , - document.body + ); + + if (embedded) { + return panel; + } + + // 挂到 body,避免页面内祖先的 transform/filter/backdrop-filter 在 Chrome 等浏览器中 + // 成为 fixed 定位的包含块,导致小窗错位到视口中上部而右下角按钮仍正常。 + if (typeof document === "undefined") { + return null; + } + + return createPortal(panel, document.body); } diff --git a/frontend/lib/chat-embed.ts b/frontend/lib/chat-embed.ts new file mode 100644 index 0000000..c253c09 --- /dev/null +++ b/frontend/lib/chat-embed.ts @@ -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; + } +} diff --git a/frontend/public/widget.js b/frontend/public/widget.js index 494c0d4..51dce32 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -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;