diff --git a/static/embed.js b/static/embed.js index 8ea80d8d35..8047cf79af 100644 --- a/static/embed.js +++ b/static/embed.js @@ -1,11 +1,18 @@ (function () { + console.log('[IFRAME] hello, embed injected') + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const button = document.createElement('div'); const iframe = document.createElement('iframe'); - let isMinMode = true; + button.id = "openim-customer-service-embed-button"; + iframe.id = "openim-customer-service-embed-iframe"; + + let isMinMode = false; let isInit = false; + const iframeSrc = "https://web.rentsoft.cn"; + const allowedOrigins = [iframeSrc]; function initUI() { Object.assign(button.style, { @@ -24,8 +31,15 @@ boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.2)", cursor: "pointer", }); + button.style.backgroundColor = '#2160fd' + button.style.padding = '8px' + const iconImgEl = document.createElement('img'); + iconImgEl.src = '/logo/iframe-enter-icon-logo.svg'; + iconImgEl.style.width = '100%'; + iconImgEl.style.height = '100%'; + button.appendChild(iconImgEl); - iframe.src = "https://openim-bot.open-sora.ai/open-kf-chatbot"; + iframe.src = iframeSrc; Object.assign(iframe.style, { position: "fixed", zIndex: "10000", @@ -48,6 +62,8 @@ }; button.onclick = function () { + console.log('[embed] click button') + const isMinWidth = window.matchMedia("(max-width: 768px)").matches || isMobile; const isHidden = iframe.style.transform === "scale(0)"; @@ -65,30 +81,61 @@ } }; - window.addEventListener('message', function (event) { - if (event.data.event === 'closeIframe') { - iframe.style.transform = "scale(0)"; - iframe.style.opacity = 0; - document.body.style.overflow = ''; - document.documentElement.style.overflow = ''; - } - if (event.data.event === 'toogleSize') { - iframe.style.width = isMinMode ? "720px" : "420px"; - iframe.style.height = isMinMode ? "80vh" : "60vh"; - isMinMode = !isMinMode; - } - if (event.data.event === 'getConfig') { - if(event.data.data){ - button.innerHTML = `` - }else { - button.innerHTML = "Ask"; - button.style.color = "white"; - button.style.backgroundColor = "#2160fd"; + let isGetConfig = false; + const rpc = setupParentRPCListener({ + allowedOrigins: allowedOrigins, // ★ 必填:子页面来源 + debug: true, + handler: async function ({ action, data }) { + if (action === 'closeIframe') { + iframe.style.transform = "scale(0)"; + iframe.style.opacity = 0; + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; } - document.body.appendChild(button); - } + if (action === 'toogleSize') { + iframe.style.width = isMinMode ? "720px" : "420px"; + iframe.style.height = isMinMode ? "80vh" : "60vh"; + isMinMode = !isMinMode; + } + if (action === 'getConfig') { + console.log('get config', data, isGetConfig); + if(isGetConfig) return; + // if(data){ + // button.style.backgroundColor = '#2160fd' + // button.style.padding = '8px' + // const iconImgEl = document.createElement('img'); + // iconImgEl.src = data.iconUrl; + // iconImgEl.style.width = '100%'; + // iconImgEl.style.height = '100%'; + // button.appendChild(iconImgEl); + // }else { + // button.innerHTML = "Ask"; + // button.style.color = "white"; + // button.style.backgroundColor = "#2160fd"; + // } + document.body.appendChild(button); + isGetConfig = true; + } + }, }); + // 在页面关闭/刷新时清理监听,避免内存泄漏,并使变量被有效使用 + try { + window.addEventListener('beforeunload', function () { + try { + if (rpc && typeof rpc.dispose === 'function') { + rpc.dispose(); + } + } catch (e) { + /* ignore dispose errors */ + console.error(e); + } + }); + } catch (e) { + /* ignore addEventListener errors */ + console.error(e); + } + window.onload = initUI; function adjustIframeStyleForSmallScreens() { @@ -114,7 +161,7 @@ } } else { Object.assign(iframe.style, { - width: isMinMode ? "420px" : "720px", + width: isMinMode ? "420px" : "640px", height: isMinMode ? "60vh" : "80vh", right: "20px", bottom: "100px", @@ -131,3 +178,121 @@ window.addEventListener('resize', adjustIframeStyleForSmallScreens); })(); + +// parent-rpc.js +/** + * 在父页面注册一个 RPC 监听器,安全响应来自 iframe 的请求。 + * - allowedOrigins: 白名单来源数组(必填!) + * - handler: async ({ action, data, event }) => any 业务处理函数 + * - respondTTL: 已响应请求ID的记忆时长,防止重复响应(ms) + * - debug: 打印调试日志 + * + * 使用指南: + * ```js + * import { setupParentRPCListener } from "/parent-rpc.js"; + * const rpc = setupParentRPCListener({ + * allowedOrigins: ["https://child.example.com"], // ★ 必填:子页面来源 + * debug: true, + * handler: async ({ action, data }) => { + * switch (action) { + * case "ping": + * return { pong: true, now: Date.now() }; + * case "sum": + * const { a, b } = data; + * return { sum: a + b }; + * default: + * throw new Error("Unknown action: " + action); + * } + * }, + * }); + * + * // 如果需要移除监听: + * // rpc.dispose(); + * ``` + */ +function setupParentRPCListener({ + allowedOrigins, + handler, + respondTTL = 60_000, + debug = false, +} = {}) { + if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) { + throw new Error("[setupParentRPCListener] 'allowedOrigins' is required."); + } + if (typeof handler !== "function") { + throw new Error("[setupParentRPCListener] 'handler' must be a function."); + } + + const responded = new Map(); // id -> expireAt + + const gc = () => { + const now = Date.now(); + for (const [id, expireAt] of responded.entries()) { + if (expireAt <= now) responded.delete(id); + } + }; + const gcTimer = setInterval(gc, Math.min(respondTTL, 10_000)); + + function log(...args) { if (debug) console.log("[ParentRPC]", ...args); } + + function isAllowedOrigin(origin) { + return allowedOrigins.includes(origin); + } + + function isValidRequest(data) { + return data + && data.__rpc === true + && data.dir === "REQUEST" + && typeof data.id === "string" + && typeof data.action === "string"; + } + + async function onMessage(event) { + try { + const { origin, data, source } = event; + if (!isAllowedOrigin(origin)) return; + if (!isValidRequest(data)) return; + + const { id, action, payload } = data; + + // 去重同一请求ID(例如 iframe 重试导致的重复投递) + if (responded.has(id)) { + log("duplicate request id, ignoring:", id); + return; + } + + log("request <-", { id, origin, action, payload }); + + let result, isError = false, errorMsg = ""; + try { + result = await handler({ action, data: payload, event }); + } catch (e) { + isError = true; + errorMsg = e?.message || String(e); + } + + const response = isError + ? { __rpc: true, dir: "RESPONSE", id, ok: false, error: { message: errorMsg } } + : { __rpc: true, dir: "RESPONSE", id, ok: true, result }; + + // 回复给来源窗口(通常是 iframe.contentWindow) + if (source && typeof source.postMessage === "function") { + source.postMessage(response, origin); + responded.set(id, Date.now() + respondTTL); + log("response ->", { id, ok: !isError }); + } + } catch (err) { + log("onMessage error:", err); + } + } + + window.addEventListener("message", onMessage); + + return { + dispose() { + clearInterval(gcTimer); + window.removeEventListener("message", onMessage); + responded.clear(); + } + }; +} diff --git a/static/logo/iframe-enter-icon-logo.svg b/static/logo/iframe-enter-icon-logo.svg new file mode 100644 index 0000000000..8cd7b44c87 --- /dev/null +++ b/static/logo/iframe-enter-icon-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + +