diff --git a/app/components/CopyTracking.tsx b/app/components/CopyTracking.tsx new file mode 100644 index 0000000..42a5f1d --- /dev/null +++ b/app/components/CopyTracking.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; + +export function CopyTracking() { + useEffect(() => { + // 监听全局 Copy 事件 + const handleCopy = () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) return; + + const text = selection.toString(); + if (!text) return; + + // Determine if it's code or text + let type = "text"; + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + + const isCode = (node: Node | null) => { + let current = node; + while (current) { + if ( + current.nodeName === "PRE" || + current.nodeName === "CODE" || + (current instanceof Element && + (current.classList.contains("code-block") || + current.tagName === "PRE" || + current.tagName === "CODE")) + ) { + return true; + } + current = current.parentNode; + } + return false; + }; + + if (isCode(anchorNode) || isCode(focusNode)) { + // 判断选中节点是否包含在
 标签内,区分代码块复制
+        type = "code";
+      }
+
+      // Umami 埋点: 记录复制行为,区分文本/代码类型和复制长度
+      if (window.umami) {
+        window.umami.track("content_copy", {
+          type,
+          content_length: text.length,
+        });
+      }
+    };
+
+    document.addEventListener("copy", handleCopy);
+    return () => document.removeEventListener("copy", handleCopy);
+  }, []);
+
+  return null;
+}
diff --git a/app/components/CustomSearchDialog.tsx b/app/components/CustomSearchDialog.tsx
index c7a4bd3..c155a58 100644
--- a/app/components/CustomSearchDialog.tsx
+++ b/app/components/CustomSearchDialog.tsx
@@ -17,6 +17,7 @@ import {
   TagsListItem,
   type SharedProps,
 } from "fumadocs-ui/components/dialog/search";
+import { useRouter } from "next/navigation";
 
 interface TagItem {
   name: string;
@@ -34,6 +35,12 @@ interface DefaultSearchDialogProps extends SharedProps {
   allowClear?: boolean;
 }
 
+interface SearchItem {
+  url?: string;
+  onSelect?: (value: string) => void;
+  [key: string]: unknown;
+}
+
 export function CustomSearchDialog({
   defaultTag,
   tags = [],
@@ -46,7 +53,12 @@ export function CustomSearchDialog({
   ...props
 }: DefaultSearchDialogProps) {
   const { locale } = useI18n();
+  const router = useRouter();
   const [tag, setTag] = useState(defaultTag);
+
+  // Extract onOpenChange to use in dependency array cleanly
+  const { onOpenChange, ...otherProps } = props;
+
   const { search, setSearch, query } = useDocsSearch(
     type === "fetch"
       ? {
@@ -65,11 +77,12 @@ export function CustomSearchDialog({
         },
   );
 
-  // Tracking logic
+  // Tracking logic for queries
   useEffect(() => {
     if (!search) return;
 
     const timer = setTimeout(() => {
+            // Umami 埋点: 搜索结果点击
       if (window.umami) {
         window.umami.track("search_query", { query: search });
       }
@@ -88,12 +101,48 @@ export function CustomSearchDialog({
     }));
   }, [links]);
 
+  // 使用 useMemo 劫持 search items,注入埋点逻辑
+  const trackedItems = useMemo(() => {
+    const data = query.data !== "empty" && query.data ? query.data : defaultItems;
+    if (!data) return [];
+
+    return data.map((item: unknown, index: number) => {
+        const searchItem = item as SearchItem;
+        return {
+          ...searchItem,
+          onSelect: (value: string) => {
+            // Umami 埋点: 搜索结果点击
+            if (window.umami) {
+              window.umami.track("search_result_click", {
+                query: search,
+                rank: index + 1,
+                url: searchItem.url,
+              });
+            }
+
+            // Call original onSelect if it exists
+            if (searchItem.onSelect) searchItem.onSelect(value);
+
+            // Handle navigation if URL exists
+            if (searchItem.url) {
+                // 显式执行路由跳转和关闭弹窗,确保点击行为能够同时触发埋点和导航
+                router.push(searchItem.url);
+                if (onOpenChange) {
+                    onOpenChange(false);
+                }
+            }
+          },
+        };
+    });
+  }, [query.data, defaultItems, search, router, onOpenChange]);
+
   return (
     
       
       
@@ -102,11 +151,8 @@ export function CustomSearchDialog({
           
           
         
-        
+        {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+        
       
       
         {tags.length > 0 && (
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx
index 61336da..14e6ae1 100644
--- a/app/components/Footer.tsx
+++ b/app/components/Footer.tsx
@@ -63,6 +63,7 @@ export function Footer() {
                   
                   
                     Mission Brief
@@ -136,6 +144,10 @@ export function Footer() {
                 
  • Network Status diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index b2831a8..45f65c9 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -83,9 +83,10 @@ export function Hero() { + + + + ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index e982a4c..7964685 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -13,6 +13,7 @@ import { import { Contributors } from "@/app/components/Contributors"; import { DocsAssistant } from "@/app/components/DocsAssistant"; import { LicenseNotice } from "@/app/components/LicenseNotice"; +import { PageFeedback } from "@/app/components/PageFeedback"; import fs from "fs/promises"; import path from "path"; @@ -92,6 +93,7 @@ export default async function DocPage({ params }: Param) { +
    diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 8e0d46a..404848d 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -4,6 +4,7 @@ import { baseOptions } from "@/lib/layout.shared"; import type { ReactNode } from "react"; import { DocsRouteFlag } from "@/app/components/RouteFlags"; import type { PageTree } from "fumadocs-core/server"; +import { CopyTracking } from "@/app/components/CopyTracking"; function pruneEmptyFolders(root: PageTree.Root): PageTree.Root { const transformNode = (node: PageTree.Node): PageTree.Node | null => { @@ -68,6 +69,7 @@ export default async function Layout({ children }: { children: ReactNode }) { return ( <> {/* Add a class on while in docs to adjust global backgrounds */} + { + // Umami 埋点: 记录 404 错误和来源页面 (Referrer) if (window.umami) { - window.umami.track("404", { path: pathname }); + window.umami.track("error_404", { + path: pathname, + referrer: document.referrer || "direct", + }); } }, [pathname]); diff --git a/docs/umami_tracking.md b/docs/umami_tracking.md index ffeb9a3..909e0a7 100644 --- a/docs/umami_tracking.md +++ b/docs/umami_tracking.md @@ -6,13 +6,15 @@ **内部导航 (Internal Navigation)**: 仅用于网站内部页面的跳转。 -| 区域 (Region) | 元素说明 | 触发行为 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | -| :------------- | :---------------------------- | :------- | :---------------------- | :------------------------------------------------------------------------------------ | -| **header** | 导航链接 (特点, 社区等) | 点击 | `navigation_click` | `region`: "header", `label`: 链接名 (e.g., "features"), `path`: 目标路径 | -| **footer** | 归档/资源链接 | 点击 | `navigation_click` | `region`: "footer", `label`: 链接名 (e.g., "AI & Mathematics"), `category`: "archive" | -| **sidebar** | 文档侧边栏链接 | 点击 | `navigation_click` | `region`: "sidebar", `label`: 页面标题, `path`: 目标路径 | -| **toc** | 目录 (Table of Contents) 链接 | 点击 | `navigation_click` | `region`: "toc", `label`: 章节标题 | -| **pagination** | 上一篇/下一篇 | 点击 | `navigation_click` | `region`: "pagination", `label`: "prev" / "next", `path`: 目标路径 | +| 区域 (Region) | 元素说明 | 触发行为 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | +| :------------------- | :---------------------------- | :------- | :---------------------- | :------------------------------------------------------------------------------------ | +| **header** | 导航链接 (特点, 社区等) | 点击 | `navigation_click` | `region`: "header", `label`: 链接名 (e.g., "features"), `path`: 目标路径 | +| **footer** | 归档/资源链接 | 点击 | `navigation_click` | `region`: "footer", `label`: 链接名 (e.g., "Mission Brief"), `category`: "archive" | +| **sidebar** | 文档侧边栏链接 | 点击 | `navigation_click` | `region`: "sidebar", `label`: 页面标题, `path`: 目标路径 | +| **toc** | 目录 (Table of Contents) 链接 | 点击 | `navigation_click` | `region`: "toc", `label`: 章节标题 | +| **pagination** | 上一篇/下一篇 | 点击 | `navigation_click` | `region`: "pagination", `label`: "prev" / "next", `path`: 目标路径 | +| **hero_cta** | 首页 Hero 按钮 | 点击 | `navigation_click` | `region`: "hero_cta", `label`: "Access Articles" | +| **home_categories** | 首页分类卡片 | 点击 | `navigation_click` | `region`: "home_categories", `label`: 分类标题 (e.g., "AI") | **资源与外部链接 (Resources & External Links)**: 用于追踪外部资源、工具或社交媒体的点击。 @@ -35,18 +37,19 @@ ## 3. 内容互动与反馈 (Content Interaction & Feedback) -| 区域 | 元素说明 | 触发行为 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | -| :--------------- | :----------------- | :------- | :---------------------- | :------------------------------------------------------------------- | -| **Content** | 复制生词/代码块 | 点击 | `prose_copy` | `type`: "code"/"text", `content_length`: 字符数范围 | -| **Content** | 页面反馈 (Helpful) | 点击 | `feedback_submit` | `page`: 当前页面路径, `vote`: "helpful" / "not_helpful" | -| **Feature** | 投稿 (Contribute) | 点击 | `contribute_trigger` | `location`: "hero" / "docs" | -| **AI Assistant** | 提问 | 发送 | `ai_assistant_query` | `length`: 字符数范围 (e.g., "0-50") _注意:不记录具体内容以保护隐私_ | +| 区域 | 元素说明 | 触发行为 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | +| :--------------- | :------------------------- | :------- | :--------------------------- | :----------------------------------------------------------- | +| **Content** | 复制生词/代码块 | 复制 | `content_copy` | `type`: "code"/"text", `content_length`: 字符数 (number) | +| **Content** | 页面反馈 (Helpful) | 点击 | `feedback_submit` | `page`: 当前页面路径, `vote`: "helpful" / "not_helpful" | +| **Feature** | 投稿 (Contribute) | 点击 | `contribute_trigger` | `location`: "hero" / "docs" | +| **Feature** | 投稿跳转 (Github Redirect) | 跳转 | `contribute_github_redirect` | `dir`: 目标目录, `filename`: 文件名 | +| **AI Assistant** | 提问 | 完成 | `ai_assistant_query` | _(暂未包含具体参数)_ | ## 4. 异常与错误 (Errors) -| 场景 | 说明 | 触发条件 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | -| :------ | :--------- | :------------ | :---------------------- | :------------------------------------- | -| **404** | 页面未找到 | 访问 404 页面 | `error_404` | `path`: 访问路径, `referrer`: 来源页面 | +| 场景 | 说明 | 触发条件 | 埋点事件名 (Event Name) | 埋点传参 (Event Data) | +| :------ | :--------- | :------------ | :---------------------- | :----------------------------------------------- | +| **404** | 页面未找到 | 访问 404 页面 | `error_404` | `path`: 访问路径, `referrer`: 来源页面 (document.referrer) | ## 实施指南 @@ -68,15 +71,23 @@ >Artificial Intelligence ``` -### 搜索埋点示例 (配合 cmdk / fumadocs) +### 搜索埋点示例 (CustomSearchDialog) ```tsx -// 在搜索组件中 -const onSelectResult = (result, index) => { - umami.track("search_result_click", { - query: searchQuery, - rank: index + 1, - url: result.url, - }); -}; +// 在搜索结果点击时 +umami.track("search_result_click", { + query: searchQuery, + rank: index + 1, + url: result.url, +}); +``` + +### 复制监听示例 (CopyTracking) + +```tsx +// 自动监听 document copy 事件 +umami.track("content_copy", { + type: isCode ? "code" : "text", + content_length: selection.toString().length, +}); ```