diff --git a/Dockerfile b/Dockerfile index 96dbe40..38d7005 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d60dc6a..cec8f23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/content/docs/overview/local-codex-canvas.mdx b/docs/content/docs/development/local-codex-canvas.mdx similarity index 100% rename from docs/content/docs/overview/local-codex-canvas.mdx rename to docs/content/docs/development/local-codex-canvas.mdx diff --git a/docs/content/docs/overview/docker.mdx b/docs/content/docs/overview/docker.mdx index a3331cb..da3d5ca 100644 --- a/docs/content/docs/overview/docker.mdx +++ b/docs/content/docs/overview/docker.mdx @@ -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。 + + +请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。 + diff --git a/docs/content/docs/support/donate.mdx b/docs/content/docs/support/donate.mdx deleted file mode 100644 index ed74a4e..0000000 --- a/docs/content/docs/support/donate.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: 打赏支持 -description: 支持无限画布项目继续维护 ---- - -# 打赏支持 - -如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。 - -项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。 - -## 支持方式 - -支持方式包括但不限于: - -- 给 GitHub 仓库点 Star,帮助项目被更多人看到。 -- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。 -- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。 - -
- 赞助地址: - - 爱发电赞助 - -
diff --git a/nginx.conf b/nginx.conf index b9cf424..35ddc1f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,4 +7,9 @@ server { location / { try_files $uri $uri/ /index.html; } + + # 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。 + location = /config.js { + add_header Cache-Control "no-store"; + } } diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh new file mode 100755 index 0000000..b2e2253 --- /dev/null +++ b/web/docker-entrypoint.sh @@ -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 <
+ diff --git a/web/public/config.js b/web/public/config.js new file mode 100644 index 0000000..4a204e2 --- /dev/null +++ b/web/public/config.js @@ -0,0 +1,3 @@ +// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件; +// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。 +window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {}; diff --git a/web/src/components/layout/analytics-tracker.tsx b/web/src/components/layout/analytics-tracker.tsx new file mode 100644 index 0000000..c8e0276 --- /dev/null +++ b/web/src/components/layout/analytics-tracker.tsx @@ -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; +} diff --git a/web/src/constant/runtime-config.ts b/web/src/constant/runtime-config.ts new file mode 100644 index 0000000..8aa432f --- /dev/null +++ b/web/src/constant/runtime-config.ts @@ -0,0 +1,30 @@ +// 运行期配置读取层。 +// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。 +// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。 +// +// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。 +// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。 + +type RuntimeConfig = { + ANALYTICS_GA4_ID?: string; // GA4 衡量 ID(G-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); + diff --git a/web/src/lib/analytics.ts b/web/src/lib/analytics.ts new file mode 100644 index 0000000..0e4dbf3 --- /dev/null +++ b/web/src/lib/analytics.ts @@ -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 = {}) { + 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 { + /* 忽略 */ + } +} + diff --git a/web/src/main.tsx b/web/src/main.tsx index d72ccfc..ad26cd1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( diff --git a/web/src/router.tsx b/web/src/router.tsx index 449fb36..69e19db 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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: ( + ), diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 667a44c..1150409 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -6,4 +6,9 @@ declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[]; interface ImportMetaEnv { // 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库) readonly VITE_DEV_PLUGINS?: string; + // 统计分析(可选,构建期注入):每家一个独立变量,填了谁就启用谁,可同时启用多家 + // GA4 衡量 ID(G-XXXX) + readonly VITE_ANALYTICS_GA4_ID?: string; + // 百度统计站点 ID + readonly VITE_ANALYTICS_BAIDU_ID?: string; }