From f439c5650a40a5c1d2f566af9e6bd68ba12099f9 Mon Sep 17 00:00:00 2001 From: haorwen Date: Fri, 13 Mar 2026 22:33:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=99widget=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E9=85=8D=E7=BD=AE=E9=A1=B9=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance widget functionality with customizable tooltip and programmatic control - Added new properties for tooltip customization: iconTitle, iconSubtitle, iconImage, and iconClosable. - Implemented JavaScript functions to open, close, and toggle the widget programmatically. - Updated widget icon component to display a tooltip with the new properties. - Enhanced localization files to include new tooltip parameters and usage examples. * chore: bump version to v0.9.59 --- package.json | 2 +- public/locales/en/setting.json | 8 +++ public/locales/zh/setting.json | 8 +++ public/widget.js | 44 +++++++++++-- src/routes/setting/Widget/ConfigDetails.tsx | 20 ++++++ src/routes/setting/Widget/index.tsx | 18 ++++- src/widget/Icon.tsx | 73 +++++++++++++++++++-- src/widget/WidgetContext.tsx | 12 ++++ src/widget/index.tsx | 15 +++++ 9 files changed, 188 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 96bbec31..b72435e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.58", + "version": "0.9.59", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 5a213e3b..e70abf6a 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -354,6 +354,10 @@ "default_value": "Default Value", "remark": "Remarks", "custom_style_tip": "if you want customize the widget position more precisely, use the widget element ID in your HTML or CSS file, like this", + "open_widget_tip": "You can use JavaScript to control the widget programmatically", + "open_widget_example": "Open widget with a button", + "open_widget_example_custom": "Custom styled button", + "open_widget_example_toggle": "Toggle widget open/close", "css_code_tip": "You can also customize CSS code, which will be loaded into the widget as a CSS file", "param_id": "Widget ID (server version>=v0.3.12)", "param_host": "Assign the user chatting with visitor(User ID)", @@ -368,6 +372,10 @@ "param_open_height": "The height while widget opened", "param_welcome": "Custom welcome message", "param_position": "Widget position: left/right", + "param_icon_title": "Hover tooltip title on closed widget icon", + "param_icon_subtitle": "Hover tooltip subtitle on closed widget icon", + "param_icon_image": "Hover tooltip image URL on closed widget icon", + "param_icon_closable": "Whether the tooltip can be closed (true/false)", "share_link": "Share the widget link so that people can chat with you directly", "widget_faq": "Enable Let Everyone Join First" }, diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index 78a3fe87..3ad0cba8 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -353,6 +353,10 @@ "default_value": "默认值", "remark": "备注", "custom_style_tip": "如果你想更精确控制挂件的样式,可以借助 widget 元素的 ID,自行写 CSS 样式,加到自己的 html 或者 css 文件中,如下所示:", + "open_widget_tip": "你可以使用 JavaScript 来控制挂件的打开和关闭", + "open_widget_example": "使用按钮打开挂件", + "open_widget_example_custom": "自定义样式的按钮", + "open_widget_example_toggle": "切换挂件开关", "css_code_tip": "也可以自定义 CSS 代码,会以 css 文件方式加载到挂件内", "param_id": "挂件 ID,可选设置 (服务器版本不低于 v0.3.12)", "param_host": "指定和谁聊天 (用户 ID)", @@ -367,6 +371,10 @@ "param_open_height": "挂件打开态的高度", "param_welcome": "自定义欢迎语", "param_position": "设置挂件位置,居左 (left) 或居右 (right)", + "param_icon_title": "关闭状态悬浮提示框标题", + "param_icon_subtitle": "关闭状态悬浮提示框副标题", + "param_icon_image": "关闭状态悬浮提示框图片地址", + "param_icon_closable": "提示框是否可关闭 (true/false)", "share_link": "分享此链接,别人可以通过该地址直接与你对话", "widget_faq": "允许公开注册" }, diff --git a/public/widget.js b/public/widget.js index dd9c76e3..79d1cf18 100644 --- a/public/widget.js +++ b/public/widget.js @@ -13,11 +13,21 @@ title = "", logo = "", position = "right", - welcome = "" + welcome = "", + iconTitle = "Need help?", + iconSubtitle = "Our staff are always ready to help!", + iconImage = "", + iconClosable = "true" } = d.currentScript.dataset; const _src = d.currentScript.src; const wrapper = d.createElement("iframe"); wrapper.id = "VOCECHAT_WIDGET"; + + // 如果有提示框内容,关闭状态的宽度和高度需要更大 + const hasTooltip = iconTitle || iconSubtitle; + const actualCloseWidth = hasTooltip ? 320 : closeWidth; + const actualCloseHeight = hasTooltip ? 120 : closeHeight; + const styles = { position: "fixed", borderRadius: "8px", @@ -33,9 +43,9 @@ new URL(_src).origin }/widget.html?id=${id}&host=${hostId}&autoReg=${autoReg}&token=${loginToken}&themeColor=${encodeURIComponent( themeColor - )}&from=${encodeURIComponent(location.hostname)}&welcome=${encodeURIComponent(welcome)}&title=${encodeURIComponent(title)}&logo=${encodeURIComponent(logo)}`; - wrapper.width = closeWidth; - wrapper.height = closeHeight; + )}&from=${encodeURIComponent(location.hostname)}&welcome=${encodeURIComponent(welcome)}&title=${encodeURIComponent(title)}&logo=${encodeURIComponent(logo)}&iconTitle=${encodeURIComponent(iconTitle)}&iconSubtitle=${encodeURIComponent(iconSubtitle)}&iconImage=${encodeURIComponent(iconImage)}&iconClosable=${iconClosable}`; + wrapper.width = actualCloseWidth; + wrapper.height = actualCloseHeight; wrapper.frameborder = 0; wrapper.referrerPolicy = "unsafe-url"; w.addEventListener( @@ -48,8 +58,8 @@ wrapper.setAttribute("height", openHeight); break; case "CLOSE": - wrapper.setAttribute("width", closeWidth); - wrapper.setAttribute("height", closeHeight); + wrapper.setAttribute("width", actualCloseWidth); + wrapper.setAttribute("height", actualCloseHeight); break; case "RELOAD_WITH_OPEN": { @@ -68,4 +78,26 @@ false ); d.body.appendChild(wrapper); + + // 暴露全局函数用于打开 widget + w.VoceChatWidget = { + open: function() { + wrapper.setAttribute("width", openWidth); + wrapper.setAttribute("height", openHeight); + wrapper.contentWindow?.postMessage("OPEN_FROM_PARENT", "*"); + }, + close: function() { + wrapper.setAttribute("width", actualCloseWidth); + wrapper.setAttribute("height", actualCloseHeight); + wrapper.contentWindow?.postMessage("CLOSE_FROM_PARENT", "*"); + }, + toggle: function() { + const currentWidth = wrapper.getAttribute("width"); + if (currentWidth == actualCloseWidth) { + this.open(); + } else { + this.close(); + } + } + }; })(window, document); diff --git a/src/routes/setting/Widget/ConfigDetails.tsx b/src/routes/setting/Widget/ConfigDetails.tsx index 17f402ea..c9dce61c 100644 --- a/src/routes/setting/Widget/ConfigDetails.tsx +++ b/src/routes/setting/Widget/ConfigDetails.tsx @@ -113,6 +113,26 @@ const ConfigDetails = ({}: Props) => { paramKey: "welcome", paramDefault: wt("welcome"), remarks: t("param_welcome") + }, + { + paramKey: "icon-title", + paramDefault: "Need help?", + remarks: t("param_icon_title") + }, + { + paramKey: "icon-subtitle", + paramDefault: "Our staff are always ready to help!", + remarks: t("param_icon_subtitle") + }, + { + paramKey: "icon-image", + paramDefault: "-", + remarks: t("param_icon_image") + }, + { + paramKey: "icon-closable", + paramDefault: "true", + remarks: t("param_icon_closable") } ].map((row) => ( diff --git a/src/routes/setting/Widget/index.tsx b/src/routes/setting/Widget/index.tsx index 879bd48c..c451cad8 100644 --- a/src/routes/setting/Widget/index.tsx +++ b/src/routes/setting/Widget/index.tsx @@ -33,7 +33,7 @@ export default function Widget() { {`\n`} @@ -45,6 +45,22 @@ export default function Widget() { bottom: 5px /* bottom position */ } `} + +
{t("open_widget_tip")}:
+ + {` + + + + + + +`}
diff --git a/src/widget/Icon.tsx b/src/widget/Icon.tsx index fd8f5593..2954c616 100644 --- a/src/widget/Icon.tsx +++ b/src/widget/Icon.tsx @@ -1,18 +1,83 @@ // import React from "react"; +import { useState } from "react"; import { shallowEqual } from "react-redux"; import { useAppSelector } from "../app/store"; +import { useWidget } from "./WidgetContext"; type Props = { handleClick: () => void; }; const Icon = ({ handleClick }: Props) => { - const logo = useAppSelector((store) => store.server.logo, shallowEqual); + const serverLogo = useAppSelector((store) => store.server.logo, shallowEqual); + const { iconTitle, iconSubtitle, iconImage, logo: customLogo, iconClosable } = useWidget(); + const [tooltipVisible, setTooltipVisible] = useState(true); + + // 优先使用自定义 logo,否则使用服务器 logo + const logo = customLogo || serverLogo; + if (!logo) return null; + return ( - +
+ {/* 常驻提示框 - 放在左边 */} + {tooltipVisible && ( +
+
+ {/* 关闭按钮 */} + {iconClosable && ( + + )} +
+ {iconImage && ( + icon + )} +
+
+ {iconTitle} +
+
+ {iconSubtitle} +
+
+
+ {/* 小三角指向右边 */} +
+
+
+ )} + + {/* Widget 图标按钮 */} + +
); }; diff --git a/src/widget/WidgetContext.tsx b/src/widget/WidgetContext.tsx index 1aa03f1f..079f8215 100644 --- a/src/widget/WidgetContext.tsx +++ b/src/widget/WidgetContext.tsx @@ -18,6 +18,10 @@ const autoReg = decodeURIComponent(query.get("autoReg") || "true") == "true"; const token = decodeURIComponent(query.get("token") || ""); const color = decodeURIComponent(query.get("themeColor") || "#1fe1f9"); const from = decodeURIComponent(query.get("from") || "widget.link"); +const iconTitle = decodeURIComponent(query.get("iconTitle") || "Need help?"); +const iconSubtitle = decodeURIComponent(query.get("iconSubtitle") || "Our staff are always ready to help!"); +const iconImage = decodeURIComponent(query.get("iconImage") || ""); +const iconClosable = decodeURIComponent(query.get("iconClosable") || "true") == "true"; const fgColor = getContrastColor(color); const embed = isInIframe(); const WidgetContext = createContext({ @@ -34,6 +38,10 @@ const WidgetContext = createContext({ title, logo, welcome, + iconTitle, + iconSubtitle, + iconImage, + iconClosable, serverVersion: "" }); @@ -60,6 +68,10 @@ function WidgetProvider({ children }: { children: ReactNode }) { inviteOnly: loginConfig?.who_can_sign_up == "InvitationOnly", title: title ? title : serverData?.name, logo: logo ? logo : serverData.logo, + iconTitle, + iconSubtitle, + iconImage, + iconClosable, serverVersion: data }} > diff --git a/src/widget/index.tsx b/src/widget/index.tsx index bb97da98..ac2c37a1 100644 --- a/src/widget/index.tsx +++ b/src/widget/index.tsx @@ -23,6 +23,7 @@ function Widget({ hostId }: Props) { } setVisible((prev) => !prev); }; + useEffect(() => { if (embed) { if (!visible) { @@ -33,6 +34,20 @@ function Widget({ hostId }: Props) { } }, [visible, embed]); + // 监听来自父窗口的消息 + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data === "OPEN_FROM_PARENT") { + setVisible(true); + } else if (event.data === "CLOSE_FROM_PARENT") { + setVisible(false); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + if (!rehydrated) return null; if (!embed) return (