diff --git a/.env.example b/.env.example index f0af896..727f97e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/page.tsx b/app/page.tsx index 24ca848..e39ab0d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 = { 男性向: [ "从小一起长大的青梅竹马,突然红着脸向我告白", @@ -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() { 目前,内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
AI 生成的内容不代表本团队立场。 -
- 本站使用开源的 Umami 进行隐私友好的匿名访问统计:不使用 Cookie、不收集个人信息、不做跨站追踪。 + {analyticsOn && ( + <> +
+ 本站使用开源的{" "} + + Umami + {" "} + 进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。 + + )}

@@ -1794,7 +1813,10 @@ export default function HomePage() { 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} diff --git a/app/play/page.tsx b/app/play/page.tsx index 4cb4867..c4b1545 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -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"); diff --git a/components/Analytics.tsx b/components/Analytics.tsx index 375e3cc..e4145b4 100644 --- a/components/Analytics.tsx +++ b/components/Analytics.tsx @@ -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 (