zlwu 8 ヶ月 前
親
コミット
bbc77227b7

BIN
dashboard/Screenshot 1.png


BIN
dashboard/Screenshot 2.png


BIN
dashboard/demo_growth.db


+ 257 - 0
dashboard/streamlit_growth_dashboard.py

@@ -0,0 +1,257 @@
+
+import os
+import sqlite3
+from datetime import date, timedelta
+import pandas as pd
+import streamlit as st
+
+st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
+
+# ---- DB connection ----
+DEFAULT_DEMO = os.path.join(os.path.dirname(__file__), "demo_growth.db")
+DB_PATH = os.getenv("DEMO_DB_PATH", DEFAULT_DEMO)
+
+if not os.path.exists(DB_PATH):
+    st.error(f"DB file not found: {DB_PATH}")
+    st.stop()
+
+conn = sqlite3.connect(DB_PATH, check_same_thread=False)
+
+def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
+    return pd.read_sql_query(sql, conn, params=params)
+
+st.title("Lyrria Growth Dashboard")
+
+# ---- Sidebar ----
+st.sidebar.header("Filters")
+
+segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
+orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
+
+segment_default = [s for s in segments if s != "internal"] if segments else []
+segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
+org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
+
+exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
+
+# Date bounds (from daily table)
+max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+
+target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
+lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
+
+# ---- Load users (for universe + test filter) ----
+users = read_df("SELECT user_id, is_test_user FROM users")
+if exclude_test:
+    users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
+universe_users = set(users["user_id"].astype(str).tolist())
+
+# ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
+# We only need recent for rolling-window sets & trend chart.
+start_recent = target_day - timedelta(days=max(lookback_days, 35))
+
+daily = read_df(
+    """
+    SELECT activity_date, user_id, active_flag, session_count, message_count,
+           first_seen_date, user_segment_snapshot, org_id_snapshot
+    FROM user_daily_activity
+    WHERE activity_date >= ? AND activity_date <= ?
+    """,
+    (start_recent.isoformat(), target_day.isoformat())
+)
+
+daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
+daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
+
+# Apply snapshot filters
+if segment_sel:
+    daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
+if org_sel:
+    daily = daily[daily["org_id_snapshot"].isin(org_sel)]
+
+# Apply user universe filter (test users removed)
+daily = daily[daily["user_id"].isin(universe_users)]
+
+# If segment/org filters are applied, universe becomes "users within that cohort".
+# We derive cohort universe from snapshots (all users who ever appeared in daily within recent window).
+# In production, if you need cohort-universe across all time, build a cohort-user table or widen this query.
+if segment_sel or org_sel:
+    cohort_universe = set(daily["user_id"].unique().tolist())
+else:
+    cohort_universe = universe_users
+
+# ---- Trend (DAU) ----
+st.subheader("DAU trend")
+trend = (
+    daily[(daily["activity_date"] >= (target_day - timedelta(days=lookback_days))) & (daily["activity_date"] <= target_day)]
+    .groupby("activity_date")["user_id"].nunique()
+    .reset_index(name="dau")
+)
+trend["activity_date"] = pd.to_datetime(trend["activity_date"])
+st.line_chart(trend.set_index("activity_date")["dau"])
+
+# ---- Segment computation on target_day ----
+d0 = target_day
+d7 = d0 - timedelta(days=7)
+d30 = d0 - timedelta(days=30)
+
+# Active sets (from recent daily window)
+active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
+active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
+active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
+
+# --- IMPORTANT: Dormant/Resurrected require "last active before today" using FULL history ---
+# We compute:
+# prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
+#
+# This query uses the daily table (fast) and avoids limiting to a 35-day window.
+# Note: For segment/org filters, we still compute last_active across all time, but only for cohort_universe users.
+params = [d0.isoformat()]
+user_filter_sql = ""
+if cohort_universe:
+    # SQLite has a limit on the number of variables; for very large cohorts, switch to temp table.
+    # For demo/small cohorts this is fine.
+    placeholders = ",".join(["?"] * len(cohort_universe))
+    user_filter_sql = f" AND user_id IN ({placeholders}) "
+    params += list(cohort_universe)
+
+last_active_df = read_df(
+    f"""
+    SELECT user_id, MAX(activity_date) AS last_active_date
+    FROM user_daily_activity
+    WHERE active_flag = 1
+      AND activity_date < ?
+      {user_filter_sql}
+    GROUP BY user_id
+    """,
+    tuple(params),
+)
+if not last_active_df.empty:
+    last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
+prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
+
+def prev_last_active_date(uid: str):
+    return prev_last_active.get(uid, None)
+
+# New users: use daily.first_seen_date (which you specified is users.created_at, written once and never overwritten)
+# Definition: first_seen_date == target_day AND active today
+# (If you want "registered today regardless of active", remove the active_today constraint.)
+first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
+new_users = {u for u in active_today if first_seen_map.get(u) == d0}
+
+# Current / Reactivated
+current_users = {u for u in active_today if u in active_past_7}
+reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
+
+# Resurrected: active today AND previous last active < d0-30 (strictly before last 30 days)
+resurrected_users = {
+    u for u in active_today
+    if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
+}
+
+# Inactive segments
+at_risk_wau = {u for u in active_past_7 if u not in active_today}
+at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
+
+# Dormant: users not active today AND (never active before today OR previous last active < d0-30)
+dormant_users = {
+    u for u in cohort_universe
+    if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
+}
+
+# ---- Segments visualization (Duolingo-like, no tables) ----
+st.subheader(f"Segments on {d0.isoformat()}")
+
+# Active buckets (composition of DAU). We add an "Other active" bucket as a safe remainder.
+other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
+
+active_buckets = [
+    ("New", len(new_users)),
+    ("Current", len(current_users)),
+    ("Reactivated", len(reactivated_users)),
+    ("Resurrected", len(resurrected_users)),
+    ("Other active", len(other_active)),
+]
+active_total = len(active_today)
+inactive_total = max(0, len(cohort_universe) - len(active_today))
+inactive_buckets = [
+    ("At-risk (7d)", len(at_risk_wau)),
+    ("At-risk (30d)", len(at_risk_mau)),
+    ("Dormant (≥30d)", len(dormant_users)),
+]
+
+def segment_card(label: str, value: int, denom: int, hint: str):
+    pct = (value / denom) if denom > 0 else 0.0
+    st.markdown(
+        f"**{label}**"
+        f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
+        unsafe_allow_html=True,
+    )
+    st.progress(min(max(pct, 0.0), 1.0))
+    st.caption(f"{pct:.1%} of {denom:,}. {hint}")
+
+# KPI tiles (top row)
+#k1, k2, k3, k4, k5 = st.columns(5)
+#k1.metric("DAU", len(active_today))
+#k2.metric("New", len(new_users))
+#k3.metric("Current", len(current_users))
+#k4.metric("Reactivated", len(reactivated_users))
+#k5.metric("Resurrected", len(resurrected_users))
+#
+#k6, k7, k8 = st.columns(3)
+#k6.metric("At-risk WAU", len(at_risk_wau))
+#k7.metric("At-risk MAU", len(at_risk_mau))
+#k8.metric("Dormant", len(dormant_users))
+
+st.markdown("")
+
+
+left, right = st.columns(2, gap="large")
+
+with left:
+    t1, t2 = st.columns([3, 1], vertical_alignment="center")
+    with t1:
+        st.markdown("### Active today composition")
+    with t2:
+        st.metric("DAU", len(active_today))
+
+    st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
+    for label, val in active_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, active_total, "Share of DAU.")
+
+with right:
+    t1, t2 = st.columns([2, 2], vertical_alignment="center")
+    with t1:
+        st.markdown("### Not active today")
+    with t2:
+        st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
+
+    st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
+    for label, val in inactive_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, inactive_total, "Share of users not active today.")
+
+
+st.markdown("---")
+# ---- Drill down ----
+st.subheader("Drill-down (first 50 user_ids)")
+tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
+with tab1:
+    st.write(sorted(list(active_today))[:50])
+with tab2:
+    st.write({
+        "at_risk_wau": sorted(list(at_risk_wau))[:50],
+        "at_risk_mau": sorted(list(at_risk_mau))[:50],
+    })
+with tab3:
+    st.write(sorted(list(dormant_users))[:50])
+
+#st.caption(
+#    "Notes: Dormant/Resurrected use prev_last_active_date computed from full user_daily_activity history "
+#    "(MAX(activity_date) where active_flag=1 and activity_date < target_day). "
+#    "If your cohort is very large, replace the IN (...) filter with a temp table join."
+#)

+ 50 - 0
dashboard/数据结构改动说明.md

@@ -0,0 +1,50 @@
+**改动概览**
+
+| 类别   | 目标                  | 影响                                                         |
+| ------ | --------------------- | ------------------------------------------------------------ |
+| 修改表 | `users`               | 新增 `user_segment`(学校/普通用户等分群);新增 `is_test_user` |
+| 新增表 | `user_events`         | 记录 app_open / message_send / page_view 等事件明细,用于 DAU/回流等后续分析 |
+| 新增表 | `user_daily_activity` | 日聚合表(看板/分层计算提速),从 `user_events`/`messages` 汇总生成 |
+
+
+
+**修改表:`users`**
+
+| 字段名         | 允许空 | 默认值       | 用途/说明                                                    |
+| -------------- | ------ | ------------ | ------------------------------------------------------------ |
+| `user_segment` | ❌      | `individual` | 用户/场景分群(例:`individual` / `school` / `internal`),用于看板筛选与功能分组 |
+| `org_id`       | ✅      |              | 用于记录学校/机构用户的具体信息                              |
+| `is_test_user` | ❌      | `false`      | 标记“账号本身是测试号”                                       |
+
+
+
+**新增表:`user_events`**
+
+| 字段名                    | 用途/说明                                                    |
+| ------------------------- | ------------------------------------------------------------ |
+| `event_id`                | 唯一事件 ID(客户端生成或服务端生成)                        |
+| `user_id`                 | 事件归属用户                                                 |
+| `event_name`              | **事件名**:app_open / page_view(比如profile、shop) / message_send(发过一句即可) |
+| `event_time`              | 事件发生时间(UTC)                                          |
+| `session_id`              | 客户端生成(同一次前台停留共享,**退出后再进入是否应该/合适算作一次新的?**) |
+| `platform`                | ios/android/web(目前唯一web,主要用于后续拓展平台)         |
+| `app_version`             | 用于版本分析/排障(默认版本号,从v0.1.x开始)                |
+| `timezone_offset_minutes` | 用户本地时区偏移(用户设备在事件发生时,相对于 UTC 的分钟偏移量) |
+| `properties`              | page_view 扩展字段:记录page名 + 从哪里进入的<br />{"page":"shop"/"user_profile"/..., "entry":"home_banner"/...} (待完善) |
+
+
+
+**新增表:`user_daily_activity`** 
+
+(数据聚合表,没有新数据,便于看盘快速查询DAU趋势/New/Current/Reactivated/Resurrected/At-risk/Dormant,避免每次扫描所有表)
+
+| 字段名                  | 说明                                                         |
+| ----------------------- | ------------------------------------------------------------ |
+| `activity_date`         | 从事件时间按 UTC 日取 `DATE(event_time)`(每天统计一次,记录该日日期,便于后续按日期查询/统计) |
+| `user_id`               |                                                              |
+| `active_flag`           | 当天有 ≥1 次 `app_open` ⇒ 1                                  |
+| `session_count`         | `COUNT(DISTINCT session_id)`(基于 `app_open`,统计一天多次使用的情况) |
+| `message_count`         | `COUNT(messages)`(建议只算 `role='user'`,同上,不同指标记录行为强度) |
+| `first_seen_date`       | 记录新用户,使用`users.created_at`;首次写入后不覆盖         |
+| `user_segment_snapshot` | 从 `users.user_segment` 取写入(用于记录当时用户的状态,避免后续身份变化导致的统计差异) |
+| `org_id_snapshot`       | 从 `users.org_id`取写入                                      |

+ 0 - 1
frontend/src/components/LanguageSwitcher.tsx

@@ -2,7 +2,6 @@
  * 语言切换组件:下拉或按钮切换当前语言,并持久化到 localStorage
  * 可放在设置页、头部或任意需要切换语言的入口
  */
-import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { SUPPORTED_LANGS, type LangCode } from '@/i18n/config';
 

+ 9 - 4
frontend/src/components/UserGuard.tsx

@@ -136,13 +136,18 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
           userId = oauthUserIdRef.current;
         }
         
-        // 并行获取用户信息和 UI 状态
-        console.log(`[UserGuard] 开始获取用户信息 - userId=${userId}, oauthUserId=${oauthUserIdRef.current}`);
+        // 并行获取用户信息和 UI 状态(此时 userId 已保证非空)
+        const uid = userId;
+        if (!uid) {
+          isCheckingRef.current = false;
+          return;
+        }
+        console.log(`[UserGuard] 开始获取用户信息 - userId=${uid}, oauthUserId=${oauthUserIdRef.current}`);
         const [userInfoResult, uiStateResult] = await Promise.all([
-          getUserInfo(userId)
+          getUserInfo(uid)
             .then((r) => ({ ok: true as const, data: r }))
             .catch((error: unknown) => ({ ok: false as const, error })),
-          getUserUIState(userId).catch((error) => {
+          getUserUIState(uid).catch((error) => {
             console.warn('[UserGuard] 获取用户UI状态失败:', error);
             return null;
           }),

+ 4 - 61
frontend/src/pages/ChatBotPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
 import { flushSync } from 'react-dom';
 import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
@@ -49,9 +49,6 @@ import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
 
-const avatarC = getOssUrl('Avatar-C.svg');
-const avatarGift = getOssUrl('Gift.gif');
-const avatarHint = getOssUrl('Avatar-hint.png');
 const tapToChat = getOssUrl('TapToChat.svg');
 const cancelIcon = getOssUrl('cancel-icon.png');
 const sendIcon = getOssUrl('send-icon.png');
@@ -202,8 +199,8 @@ export const ChatBotPage = () => {
   const subtitleLastFrameTimeRef = useRef<number | null>(null);
   const sendTimeRef = useRef<number>(0);
   const isMountedRef = useRef(true);
-  const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
-  const [hasSharedAvatar, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
+  const [, setUserLevelValue] = useState(() => getUserLevel());
+  const [, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
   // 背景图片 URL
   const [backgroundImageUrl, setBackgroundImageUrl] = useState<string | null>(() => {
     // 初始化时从 localStorage 获取背景图片 URL
@@ -456,7 +453,7 @@ export const ChatBotPage = () => {
   }, [recognizedText, subtitleVisible, currentState]);
   
   // V2: 完全计算式布局系统
-  const [layout, setLayout] = useState<CalculatedLayout>(() => {
+  const [, setLayout] = useState<CalculatedLayout>(() => {
     const initialLayout = calculateLayout();
     return initialLayout;
   });
@@ -503,27 +500,6 @@ export const ChatBotPage = () => {
   const emotionGifPlayedThisRoundRef = useRef<boolean>(false);
   // 当前正在累积字幕的 requestId;同一次对话多段音频的文字做拼接,新请求时重置
   const subtitleAccumulatorRequestIdRef = useRef<string | null>(null);
-  const handleAvatarClick = useCallback(() => {
-    if (!isAvatarCUnlocked) {
-      return;
-    }
-    const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
-    if (isLoggedIn) {
-      navigate('/profile');
-      return;
-    }
-    navigate('/level34-welcome');
-  }, [isAvatarCUnlocked, navigate]);
-  const handleAvatarKeyDown = useCallback(
-    (event: KeyboardEvent<HTMLDivElement>) => {
-      if (event.key === 'Enter' || event.key === ' ') {
-        event.preventDefault();
-        handleAvatarClick();
-      }
-    },
-    [handleAvatarClick],
-  );
-  
   const timingRef = useRef({
     recordStart: 0,
     recordEnd: 0,
@@ -1016,13 +992,6 @@ export const ChatBotPage = () => {
     }
   }, [currentState, stopRecordingTimer]);
 
-  const formatCountdown = (ms: number) => {
-    const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
-    const minutes = Math.floor(totalSeconds / 60);
-    const seconds = totalSeconds % 60;
-    return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
-  };
-
   /** 录音 50~60 秒已用时间显示:00:50、00:51 … 01:00(仅在剩余 ≤10 秒时用) */
   const formatElapsed50to60 = (remainingMs: number): string => {
     const elapsedSeconds = Math.min(60, Math.max(50, Math.floor((MAX_RECORDING_DURATION_MS - remainingMs) / 1000)));
@@ -2859,8 +2828,6 @@ export const ChatBotPage = () => {
     reset();
   };
 
-  const avatarHintSize = Math.round(layout.avatarCSize * 1.8);
-
   const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
   const energyStoneAssets: Record<'mission' | 'conversation', string> = {
     mission: missionEnergyStone,
@@ -2887,21 +2854,6 @@ export const ChatBotPage = () => {
     [spawnStone, fetchConversationStoneCount]
   );
 
-  // 获取用户名的首字母(英文大写,汉字保持原样)
-  const getUserInitial = (): string => {
-    const userName = getStorage(STORAGE_KEYS.USER_NAME);
-    if (!userName || userName.length === 0) {
-      return '';
-    }
-    const firstChar = userName[0];
-    // 判断是否为英文字母
-    if (/[a-zA-Z]/.test(firstChar)) {
-      return firstChar.toUpperCase();
-    }
-    // 汉字或其他字符保持原样
-    return firstChar;
-  };
-
   // 根据背景图片获取对应的树木装饰图片URL(默认背景下录音时切到 bg-chatting.png,发送/取消后恢复)
   const getTreesImage = (): string | null => {
     const bgId = getCurrentBackgroundImageId();
@@ -2934,15 +2886,6 @@ export const ChatBotPage = () => {
     return { ...baseStyle, opacity };
   };
 
-  const userInitial = getUserInitial();
-  
-  // 计算 Avatar-C 头像的显示逻辑
-  const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
-  // 图片选择:未登录或已登录且已分享 → avatarC,已登录且未分享 → avatarGift
-  const avatarSrc = (!isLoggedIn || hasSharedAvatar) ? avatarC : avatarGift;
-  // 首字母显示:未登录或已登录且已分享 → 显示,已登录且未分享 → 不显示
-  const shouldShowInitial = userInitial && (!isLoggedIn || hasSharedAvatar);
-
   const defaultGifUrl = avatarGifSet?.default ?? '';
   // 双 img 固定角色:default 层永远 default;对话层 playing 时播 emotion/talking
   // 当对话层切换到下面(idle)时,直接把对话层 src 设为 default,下次对话无需再关心其是否已恢复默认

+ 1 - 1
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -23,7 +23,7 @@ interface LocationState {
 }
 
 /** 手机号脱敏:1xxxxx 形式,如 138****5678 */
-function maskPhone(phone: string, areaCode: string): string {
+function maskPhone(phone: string, _areaCode: string): string {
   if (!phone) return '';
   const digits = phone.replace(/\D/g, '');
   if (digits.length <= 4) return digits;

+ 1 - 2
frontend/src/pages/ProfilePage.tsx

@@ -31,7 +31,6 @@ import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
 
 const closeBtn = getOssUrl('close-btn.png');
 const editIcon = getOssUrl('mode_edit_outline.png');
-const missionEnergyStoneCount = getOssUrl('mission-energy-stone-count.png');
 const conversationEnergyStoneCount = getOssUrl('conversion-energy-stone-count.png');
 const lumiNoCap = getOssUrl('Lumi-no-cap.png');
 const lumiCap = getOssUrl('Lumi-cap.png');
@@ -51,7 +50,7 @@ export const ProfilePage = () => {
   const navigate = useNavigate();
   const [username, setUsername] = useState<string>('');
   const [botName, setBotName] = useState<string>('');
-  const [missionStoneCount, setMissionStoneCount] = useState<number>(0);
+  const [, setMissionStoneCount] = useState<number>(0);
   const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
   const [hasCap, setHasCap] = useState<boolean>(false);
   const [hasBackgroundReward, setHasBackgroundReward] = useState<boolean>(false);

+ 0 - 2
frontend/src/utils/audioPlayer.ts

@@ -238,8 +238,6 @@ export class AudioPlayer {
   markStreamEnd(): void {
     this.isStreamEnded = true;
     this.beforeEndFired = false;
-    const sourcesCount = this.currentSources.length;
-    const queueLen = this.audioQueue.length;
     const remainingSec = this.audioContext
       ? Math.max(0, (this.nextStartTime - this.audioContext.currentTime) / this.playbackRate)
       : 0;

+ 2 - 1
frontend/src/utils/storage.ts

@@ -30,8 +30,9 @@ export const STORAGE_KEYS = {
   CURRENT_AVATAR_ID: 'Lyrria_current_avatar_id', // 当前形象ID
 } as const;
 
-// V2.0 路由顺序表:仅登录后新用户流程(welcome → naming → lumi-naming → chatbot)
+// V2.0 路由顺序表:begin → welcome → naming → lumi-naming → chatbot
 export const ROUTE_ORDER = [
+  '/begin', // 开始页
   '/welcome', // 欢迎页(树叶 + Start your journey → MP4)
   '/naming', // 用户命名页
   '/lumi-naming', // Lumi 命名页