feat(analytics): implement runtime configuration and analytics tracking for GA4 and Baidu

This commit is contained in:
HouYunFei
2026-07-17 11:24:22 +08:00
parent a2586ffe34
commit ca6efdf0c3
15 changed files with 216 additions and 25 deletions
+2
View File
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
COPY --from=web-build /app/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
EXPOSE 3000
+4
View File
@@ -5,3 +5,7 @@ services:
ports:
- "3000:3000"
restart: unless-stopped
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
# environment:
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
+39
View File
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
## 数据说明
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
## 统计分析(可选)
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
| 变量 | 说明 |
| --- | --- |
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID`G-XXXX` |
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID`hm.js?` 后面那串 hash |
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
```yaml
services:
app:
image: ghcr.io/basketikun/infinite-canvas:latest
ports:
- "3000:3000"
environment:
ANALYTICS_GA4_ID: G-XXXXXXXXXX
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
```
或使用 `docker run`
```bash
docker run -d -p 3000:3000 \
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
ghcr.io/basketikun/infinite-canvas:latest
```
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
<Callout type="warn">
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
</Callout>
-25
View File
@@ -1,25 +0,0 @@
---
title: 打赏支持
description: 支持无限画布项目继续维护
---
# 打赏支持
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
## 支持方式
支持方式包括但不限于:
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
</a>
</div>
+5
View File
@@ -7,4 +7,9 @@ server {
location / {
try_files $uri $uri/ /index.html;
}
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
location = /config.js {
add_header Cache-Control "no-store";
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
sanitize_id() {
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
}
GA4_ID=$(sanitize_id "${ANALYTICS_GA4_ID:-}")
BAIDU_ID=$(sanitize_id "${ANALYTICS_BAIDU_ID:-}")
cat > /usr/share/nginx/html/config.js <<EOF
window.__RUNTIME_CONFIG__ = {
ANALYTICS_GA4_ID: "${GA4_ID}",
ANALYTICS_BAIDU_ID: "${BAIDU_ID}"
};
EOF
+1
View File
@@ -17,6 +17,7 @@
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { trackPageview } from "@/lib/analytics";
// 监听 SPA 路由变化并上报 pageview。无统计配置时 trackPageview 为空操作。
export function AnalyticsTracker() {
const location = useLocation();
useEffect(() => {
trackPageview(`${location.pathname}${location.search}`);
}, [location.pathname, location.search]);
return null;
}
+30
View File
@@ -0,0 +1,30 @@
// 运行期配置读取层。
// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。
// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。
//
// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。
type RuntimeConfig = {
ANALYTICS_GA4_ID?: string; // GA4 衡量 IDG-XXXX
ANALYTICS_BAIDU_ID?: string; // 百度统计站点 ID
};
declare global {
interface Window {
__RUNTIME_CONFIG__?: RuntimeConfig;
}
}
const runtime: RuntimeConfig = (typeof window !== "undefined" && window.__RUNTIME_CONFIG__) || {};
function read(key: keyof RuntimeConfig, buildTime: string | undefined, fallback = ""): string {
const value = runtime[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof buildTime === "string" && buildTime.trim()) return buildTime.trim();
return fallback;
}
export const ANALYTICS_GA4_ID = read("ANALYTICS_GA4_ID", import.meta.env.VITE_ANALYTICS_GA4_ID);
export const ANALYTICS_BAIDU_ID = read("ANALYTICS_BAIDU_ID", import.meta.env.VITE_ANALYTICS_BAIDU_ID);
+85
View File
@@ -0,0 +1,85 @@
// 统计分析加载器:默认关闭,可同时启用多家。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接——
// 不接受任意脚本 URL / 内联 JS,避免「配置项被用来在访客浏览器执行任意代码」。
// 全空时不注入任何脚本、不发任何外部请求。这是开源项目的硬要求:
// fork/自托管者默认零统计,官方站点仅通过环境变量注入自己的 ID(ID 不入库)。
import { ANALYTICS_BAIDU_ID, ANALYTICS_GA4_ID } from "@/constant/runtime-config";
type GtagFn = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: unknown[];
gtag?: GtagFn;
_hmt?: unknown[][];
}
}
let initialized = false;
// 记录实际启用了哪些统计,供路由上报时按需分发。
const active = { ga4: false, baidu: false };
function appendScript(src: string, attrs: Record<string, string> = {}) {
const el = document.createElement("script");
el.async = true;
el.src = src;
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
document.head.appendChild(el);
return el;
}
function initGa4(id: string) {
window.dataLayer = window.dataLayer || [];
const gtag: GtagFn = (...args) => {
window.dataLayer!.push(args);
};
window.gtag = gtag;
appendScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`);
gtag("js", new Date());
// SPA 路由上报交给 trackPageview,这里关闭默认的自动 page_view,避免重复。
gtag("config", id, { send_page_view: false });
active.ga4 = true;
}
function initBaidu(id: string) {
window._hmt = window._hmt || [];
appendScript(`https://hm.baidu.com/hm.js?${encodeURIComponent(id)}`);
active.baidu = true;
}
export function initAnalytics() {
if (initialized || typeof window === "undefined") return;
initialized = true;
// 各家相互独立,逐个判断并启用;任一家出错都不影响其它家与主应用。
if (ANALYTICS_GA4_ID) {
try {
initGa4(ANALYTICS_GA4_ID);
} catch {
/* 忽略 */
}
}
if (ANALYTICS_BAIDU_ID) {
try {
initBaidu(ANALYTICS_BAIDU_ID);
} catch {
/* 忽略 */
}
}
}
// SPA 路由切换时上报页面浏览,分发给所有已启用的统计。
export function trackPageview(path: string) {
try {
if (active.ga4 && window.gtag) {
window.gtag("event", "page_view", { page_path: path, page_location: window.location.href });
}
if (active.baidu && window._hmt) {
window._hmt.push(["_trackPageview", path]);
}
} catch {
/* 忽略 */
}
}
+3
View File
@@ -6,8 +6,11 @@ import "./styles/globals.css";
import { RouterProvider } from "react-router-dom";
import { AppProviders } from "@/components/layout/app-providers";
import { initAnalytics } from "@/lib/analytics";
import { router } from "@/router";
initAnalytics();
document.body.style.fontFamily = '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif';
createRoot(document.getElementById("root")!).render(
+2
View File
@@ -1,5 +1,6 @@
import { createBrowserRouter, Outlet } from "react-router-dom";
import { AnalyticsTracker } from "@/components/layout/analytics-tracker";
import UserLayout from "@/layouts/user-layout";
import AssetsPage from "@/pages/assets";
import CanvasPage from "@/pages/canvas";
@@ -15,6 +16,7 @@ export const router = createBrowserRouter([
{
element: (
<UserLayout>
<AnalyticsTracker />
<Outlet />
</UserLayout>
),
+5
View File
@@ -6,4 +6,9 @@ declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
interface ImportMetaEnv {
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
readonly VITE_DEV_PLUGINS?: string;
// 统计分析(可选,构建期注入):每家一个独立变量,填了谁就启用谁,可同时启用多家
// GA4 衡量 IDG-XXXX
readonly VITE_ANALYTICS_GA4_ID?: string;
// 百度统计站点 ID
readonly VITE_ANALYTICS_BAIDU_ID?: string;
}