Explorar o código

修复嵌入双按钮问题

537yaha hai 1 mes
pai
achega
ad0c9fddc9

+ 2 - 1
README.md

@@ -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
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<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
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 = (
     <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 - 0
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;
+  }
+}

+ 4 - 1
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;