Merge pull request #26 from zonghaoyuan/feat/umami-events
feat(web): 隐私友好的 Umami 自定义埋点
This commit is contained in:
+13
-2
@@ -73,7 +73,18 @@ NEXT_PUBLIC_IMAGE_PROXY_URL=
|
||||
# NEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js
|
||||
# Self-host later: point SRC at your own instance — the integration is identical
|
||||
# (no code change), e.g. NEXT_PUBLIC_UMAMI_SRC=https://stats.example.com/script.js
|
||||
# Both blank → no script is injected (zero tracking). NEXT_PUBLIC_ vars are
|
||||
# inlined at BUILD time, so set them in the build env (Vercel project settings).
|
||||
# Both blank → no script is injected (zero tracking; every track() call no-ops).
|
||||
# Beyond page views the app emits content-free custom events (game start, scene
|
||||
# reached, choice picked, ...) — only enums/counts/booleans, never your prompts,
|
||||
# uploaded images or any per-user ID. The visitor's Do-Not-Track is honoured.
|
||||
# NEXT_PUBLIC_ vars are inlined at BUILD time, so set them in the build env
|
||||
# (Vercel project settings).
|
||||
NEXT_PUBLIC_UMAMI_SRC=
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
|
||||
# Optional hostname allowlist — defense-in-depth on top of the blank-to-disable
|
||||
# gate above. The tracker fires only when window.location.hostname EXACTLY
|
||||
# matches an entry, so a fork that copied these vars stays silent on its own
|
||||
# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards.
|
||||
# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
|
||||
NEXT_PUBLIC_UMAMI_DOMAINS=
|
||||
|
||||
+56
-34
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
import {
|
||||
ART_STYLES,
|
||||
GENDERS,
|
||||
PACINGS,
|
||||
PLOT_STYLES,
|
||||
type Gender,
|
||||
} from "@/lib/options";
|
||||
|
||||
/* ============================================================================
|
||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||
@@ -20,8 +28,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
========================================================================== */
|
||||
|
||||
|
||||
type Gender = "男性向" | "女性向";
|
||||
|
||||
const EXAMPLE_PHRASES: Record<Gender, string[]> = {
|
||||
男性向: [
|
||||
"从小一起长大的青梅竹马,突然红着脸向我告白",
|
||||
@@ -44,33 +50,11 @@ type Opt = {
|
||||
};
|
||||
|
||||
const OPTS: Opt[] = [
|
||||
{ label: "性向", items: ["男性向", "女性向"] },
|
||||
{
|
||||
label: "绘画风格",
|
||||
modal: true,
|
||||
items: [
|
||||
"自动",
|
||||
"自定义",
|
||||
"京阿尼细腻日常",
|
||||
"新海诚唯美光影",
|
||||
"Galgame CG",
|
||||
"3D 动漫电影",
|
||||
"赛博朋克",
|
||||
"蒸汽波",
|
||||
"吉卜力治愈手绘",
|
||||
"哥特庄园",
|
||||
"废土科幻",
|
||||
// 以下为小众/区域性画风,留作长尾选项
|
||||
"古典厚涂油画",
|
||||
"极简中国水墨",
|
||||
"浮世绘木刻",
|
||||
"莫高窟壁画",
|
||||
"波斯细密画",
|
||||
],
|
||||
},
|
||||
{ label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 },
|
||||
{ label: "性向", items: [...GENDERS] },
|
||||
{ label: "绘画风格", modal: true, items: [...ART_STYLES] },
|
||||
{ label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 },
|
||||
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
|
||||
{ label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 },
|
||||
{ label: "内容节奏", items: [...PACINGS], defaultIndex: 1 },
|
||||
];
|
||||
|
||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||
@@ -1008,9 +992,11 @@ function StyleModal({
|
||||
// 用户事后还可以手动改 draft(仍是 textarea)。
|
||||
setDraft(data.stylePrompt);
|
||||
setCustomStyleRefImage(resized);
|
||||
track("style_image_upload", { ok: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "解析失败";
|
||||
setParseError(msg);
|
||||
track("style_image_upload", { ok: false });
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
@@ -1472,10 +1458,10 @@ export default function HomePage() {
|
||||
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
|
||||
const userPrompt =
|
||||
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
||||
const artStyle = OPTS[1]!.items[sel[1] ?? 0]!;
|
||||
const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!;
|
||||
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
||||
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
||||
const pace = OPTS[4]!.items[sel[4] ?? 1]!;
|
||||
const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快";
|
||||
|
||||
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
||||
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
||||
@@ -1524,6 +1510,17 @@ export default function HomePage() {
|
||||
const styleReferenceImage =
|
||||
artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined;
|
||||
|
||||
track("game_start", {
|
||||
source: "prompt",
|
||||
gender,
|
||||
art_style: artStyle,
|
||||
plot_style: plotStyle,
|
||||
pacing: pace,
|
||||
tts: audioEnabled,
|
||||
has_prompt: prompt.trim().length > 0,
|
||||
has_style_ref: Boolean(styleReferenceImage),
|
||||
});
|
||||
|
||||
sessionStorage.setItem(
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }),
|
||||
@@ -1533,6 +1530,9 @@ export default function HomePage() {
|
||||
|
||||
const stories = STORIES[galleryGender];
|
||||
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
|
||||
const analyticsOn = Boolean(
|
||||
process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
);
|
||||
|
||||
// 点卡片 = 直接开始这张卡的故事,零等待:跳 /play?card=m0/f0... 由 /play
|
||||
// 页面从 /home/firstact/{name}.json 静态文件加载预烘焙好的首幕(含 scene /
|
||||
@@ -1547,6 +1547,12 @@ export default function HomePage() {
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }),
|
||||
);
|
||||
track("game_start", {
|
||||
source: "curated",
|
||||
gender: galleryGender,
|
||||
tts: audioEnabled,
|
||||
card: `${imgPrefix}${idx}`,
|
||||
});
|
||||
router.push(`/play?card=${imgPrefix}${idx}`);
|
||||
};
|
||||
|
||||
@@ -1778,8 +1784,21 @@ export default function HomePage() {
|
||||
目前,内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
|
||||
<br />
|
||||
AI 生成的内容不代表本团队立场。
|
||||
<br />
|
||||
本站使用开源的 Umami 进行隐私友好的匿名访问统计:不使用 Cookie、不收集个人信息、不做跨站追踪。
|
||||
{analyticsOn && (
|
||||
<>
|
||||
<br />
|
||||
本站使用开源的{" "}
|
||||
<a
|
||||
href="https://umami.is/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-clay-900/20 underline-offset-2 transition-colors hover:text-clay-700"
|
||||
>
|
||||
Umami
|
||||
</a>{" "}
|
||||
进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1794,7 +1813,10 @@ export default function HomePage() {
|
||||
<StyleModal
|
||||
items={OPTS[styleRow]!.items}
|
||||
value={sel[styleRow] ?? 0}
|
||||
onPick={(i) => setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
|
||||
onPick={(i) => {
|
||||
track("art_style_select", { style: ART_STYLES[i] ?? "自动" });
|
||||
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)));
|
||||
}}
|
||||
onClose={() => setStyleOpen(false)}
|
||||
customStyleGuide={customStyleGuide}
|
||||
setCustomStyleGuide={setCustomStyleGuide}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
StartResponse,
|
||||
VisionResponse,
|
||||
} from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||
|
||||
@@ -373,6 +374,21 @@ function PlayInner() {
|
||||
mutedRef.current = muted;
|
||||
}, [muted]);
|
||||
|
||||
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
||||
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
||||
// while the tab is visible) gives Umami the timestamps to derive real
|
||||
// engaged time. Content-free — no payload. The interval is never even
|
||||
// scheduled unless the tracker is configured, so it's zero work when off.
|
||||
useEffect(() => {
|
||||
if (!process.env.NEXT_PUBLIC_UMAMI_SRC || !process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) {
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") track("play_heartbeat");
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
|
||||
useEffect(() => {
|
||||
if (!currentBeatId) return;
|
||||
@@ -472,6 +488,7 @@ function PlayInner() {
|
||||
|
||||
// ── Mute persistence (read is via the useState lazy initializer above) ─
|
||||
const toggleMuted = useCallback(() => {
|
||||
track("tts_toggle", { muted: !mutedRef.current });
|
||||
setMuted((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
@@ -507,6 +524,7 @@ function PlayInner() {
|
||||
// ── Presentation mode toggle ─────────────────────────────────────────
|
||||
const togglePresentation = useCallback(async () => {
|
||||
const entering = !presentation;
|
||||
track("fullscreen_toggle", { on: entering });
|
||||
if (entering) {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -671,6 +689,7 @@ function PlayInner() {
|
||||
// beatAudioMap is populated lazily by the per-beat fetch effect once
|
||||
// currentScene becomes non-null (see fetchBeatAudio).
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: initial.history.length });
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
}, [params, router]);
|
||||
@@ -782,6 +801,7 @@ function PlayInner() {
|
||||
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
|
||||
setLastExitLabel(exitLabel);
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: newSession.history.length });
|
||||
} catch (e) {
|
||||
if ((e as { name?: string }).name === "AbortError") {
|
||||
setPhase("ready");
|
||||
@@ -795,6 +815,19 @@ function PlayInner() {
|
||||
function onSelectChoice(choice: BeatChoice) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
|
||||
const beatNext = currentBeatRef.current?.next;
|
||||
const choiceIndex =
|
||||
beatNext?.type === "choice"
|
||||
? beatNext.choices.findIndex((c) => c.id === choice.id)
|
||||
: -1;
|
||||
if (choiceIndex >= 0) {
|
||||
track("choice_select", {
|
||||
scene_index: session.history.length,
|
||||
choice_index: choiceIndex,
|
||||
kind: choice.effect.kind,
|
||||
});
|
||||
}
|
||||
|
||||
if (choice.effect.kind === "advance-beat") {
|
||||
// Pure local jump. No network. No pool changes.
|
||||
setCurrentBeatId(choice.effect.targetBeatId);
|
||||
@@ -863,6 +896,7 @@ function PlayInner() {
|
||||
throw new Error(j.error ?? visionRes.statusText);
|
||||
}
|
||||
const decision = (await visionRes.json()) as VisionResponse;
|
||||
track("vision_click", { result: decision.classify });
|
||||
|
||||
if (decision.classify === "insert-beat") {
|
||||
setPhase("inserting-beat");
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import Script from "next/script";
|
||||
|
||||
// Privacy-friendly, cookieless page-view analytics (Umami). Both env vars
|
||||
// unset → render nothing, so local dev and forks never report to our instance.
|
||||
// Privacy-friendly, cookieless analytics (Umami). Both env vars unset →
|
||||
// render nothing, so local dev and forks never report to our instance.
|
||||
// - data-do-not-track: honour the visitor's browser Do Not Track setting.
|
||||
// - data-domains (NEXT_PUBLIC_UMAMI_DOMAINS): extra guard — the tracker only
|
||||
// fires when the live hostname matches, so even a fork that copied our env
|
||||
// vars stays silent on a different domain. Unset → run on all hosts.
|
||||
export function Analytics() {
|
||||
const src = process.env.NEXT_PUBLIC_UMAMI_SRC;
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
|
||||
if (!src || !websiteId) return null;
|
||||
|
||||
return (
|
||||
<Script
|
||||
src={src}
|
||||
data-website-id={websiteId}
|
||||
data-do-not-track="true"
|
||||
{...(domains ? { "data-domains": domains } : {})}
|
||||
strategy="afterInteractive"
|
||||
defer
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
export function CustomForm() {
|
||||
const router = useRouter();
|
||||
@@ -22,6 +23,7 @@ export function CustomForm() {
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting, styleGuide }),
|
||||
);
|
||||
track("game_start", { source: "custom" });
|
||||
router.push("/play?custom=1");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Privacy-first analytics. Sends only content-free, categorical events to
|
||||
// Umami, and only when the tracker script is actually present (gated by the
|
||||
// NEXT_PUBLIC_UMAMI_* env vars in components/Analytics.tsx). With no script
|
||||
// loaded — local dev, forks, a non-matching data-domains host, or a visitor
|
||||
// with Do Not Track — `window.umami` is undefined and every call here is a
|
||||
// silent no-op: zero runtime impact, no errors.
|
||||
//
|
||||
// RULE: never pass free text (player prompts, custom world/style guides,
|
||||
// uploaded images, vision output) or any per-user identifier. Only enums,
|
||||
// indices, counts and booleans — that is what keeps these events as
|
||||
// privacy-friendly as the cookieless page-view baseline.
|
||||
|
||||
import type { ArtStyle, Gender, Pacing, PlotStyle } from "./options";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
||||
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
||||
// world/style guide or vision string, so free text can't be attached by mistake
|
||||
// (a bare `Record<string, string>` would happily accept it). Every field is a
|
||||
// literal union (shared with the selector UI via ./options), index, count or
|
||||
// boolean — never a bare `string`. `never` marks events that carry no payload.
|
||||
type AnalyticsEventData = {
|
||||
game_start:
|
||||
| {
|
||||
source: "prompt";
|
||||
gender: Gender;
|
||||
art_style: ArtStyle;
|
||||
plot_style: PlotStyle;
|
||||
pacing: Pacing;
|
||||
tts: boolean;
|
||||
has_prompt: boolean;
|
||||
has_style_ref: boolean;
|
||||
}
|
||||
| { source: "curated"; gender: Gender; tts: boolean; card: `${"m" | "f"}${number}` }
|
||||
| { source: "custom" };
|
||||
art_style_select: { style: ArtStyle };
|
||||
style_image_upload: { ok: boolean };
|
||||
scene_reached: { scene_index: number };
|
||||
choice_select: {
|
||||
scene_index: number;
|
||||
choice_index: number;
|
||||
kind: "advance-beat" | "change-scene";
|
||||
};
|
||||
vision_click: { result: "insert-beat" | "change-scene" };
|
||||
tts_toggle: { muted: boolean };
|
||||
fullscreen_toggle: { on: boolean };
|
||||
play_heartbeat: never;
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = keyof AnalyticsEventData;
|
||||
|
||||
// Payload is required for events that define one and forbidden for those typed
|
||||
// `never` (the conditional rest tuple collapses to `[]`), so `track("game_start")`
|
||||
// without data and `track("play_heartbeat", {...})` with data are both errors.
|
||||
export function track<E extends AnalyticsEvent>(
|
||||
event: E,
|
||||
...[data]: AnalyticsEventData[E] extends never ? [] : [AnalyticsEventData[E]]
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.umami?.track(event, data as Record<string, unknown> | undefined);
|
||||
} catch {
|
||||
// Analytics must never throw into the app.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Single source of truth for the home-page selector option sets. Kept as
|
||||
// `as const` so each list also yields a literal-union type: the play-start
|
||||
// UI (app/page.tsx) renders from the arrays, and the analytics schema
|
||||
// (lib/analytics.ts) types its payload fields from the unions. That shared
|
||||
// origin is what keeps the "content-free" events honest — an event field can
|
||||
// only ever be one of these fixed labels, never free-form player text.
|
||||
|
||||
export const GENDERS = ["男性向", "女性向"] as const;
|
||||
|
||||
export const ART_STYLES = [
|
||||
"自动",
|
||||
"自定义",
|
||||
"京阿尼细腻日常",
|
||||
"新海诚唯美光影",
|
||||
"Galgame CG",
|
||||
"3D 动漫电影",
|
||||
"赛博朋克",
|
||||
"蒸汽波",
|
||||
"吉卜力治愈手绘",
|
||||
"哥特庄园",
|
||||
"废土科幻",
|
||||
// 以下为小众/区域性画风,留作长尾选项
|
||||
"古典厚涂油画",
|
||||
"极简中国水墨",
|
||||
"浮世绘木刻",
|
||||
"莫高窟壁画",
|
||||
"波斯细密画",
|
||||
] as const;
|
||||
|
||||
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
||||
|
||||
export const PACINGS = ["慢热细腻", "紧凑爽快"] as const;
|
||||
|
||||
export type Gender = (typeof GENDERS)[number];
|
||||
export type ArtStyle = (typeof ART_STYLES)[number];
|
||||
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
||||
export type Pacing = (typeof PACINGS)[number];
|
||||
Reference in New Issue
Block a user