zlwu 6 месяцев назад
Родитель
Сommit
bdd7126e7c

+ 226 - 60
dashboard/streamlit_growth_dashboard.py

@@ -18,6 +18,131 @@ except ImportError:
 
 st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
 
+# ---- UI i18n ----
+I18N = {
+    "zh": {
+        "dashboard_title": "Lyrria 增长看板",
+        "sidebar_lang": "Language / 语言",
+        "sidebar_filters": "筛选器",
+        "sidebar_segment": "用户分层(users.user_segment)",
+        "sidebar_school": "学校(org_name)",
+        "sidebar_exclude_test": "排除 users.is_test_user = 1",
+        "sidebar_target_date": "目标日期",
+        "sidebar_view_members": "查看共创社区成员",
+        "community_dialog_title": "共创社区成员",
+        "community_subheader": "共创社区成员",
+        "community_rule": "规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。",
+        "community_count": "参与成员数",
+        "community_empty": "当前没有符合条件的共创社区成员。",
+        "dau_trend": "DAU 趋势",
+        "curr_trend": "CURR 趋势",
+        "axis_date": "日期",
+        "axis_dau": "DAU",
+        "axis_curr": "CURR(%)",
+        "tooltip_curr": "CURR(%)",
+        "tooltip_prev_day_active": "昨日活跃",
+        "tooltip_retained_today": "今日留存",
+        "tooltip_dau_today": "今日 DAU",
+        "trend3_caption_1": "30 天走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。",
+        "trend3_caption_2": "图例说明:蓝线=对话时长(秒);橙线=Lumi 商店 PV;绿线=Lumi 装扮 PV。",
+        "line_duration": "对话时长(秒)",
+        "line_shop_pv": "Lumi 商店 PV",
+        "line_outfits_pv": "Lumi 装扮 PV",
+        "legend_lines": "曲线",
+        "axis_duration": "对话时长(秒)",
+        "axis_pv_count": "页面浏览量(次)",
+        "tooltip_pv_type": "PV 类型",
+        "tooltip_pv": "PV",
+        "segments_on": "分群(日期:{date})",
+        "segment_new": "新用户",
+        "segment_current": "持续活跃",
+        "segment_reactivated": "重新活跃",
+        "segment_at_risk_7d": "风险流失(7 天)",
+        "segment_at_risk_30d": "风险流失(30 天)",
+        "segment_dormant_30d": "沉睡(>=30 天)",
+        "active_today_composition": "今日活跃构成",
+        "not_active_today": "今日未活跃",
+        "metric_inactive_total": "未活跃 / 总用户",
+        "caption_active_comp": "今日 DAU 的构成。",
+        "caption_inactive_comp": "当前筛选人群中,今日未活跃用户的分布。",
+        "hint_share_dau": "占 DAU 的比例。",
+        "hint_share_inactive": "占今日未活跃用户的比例。",
+        "caption_of": "占 {denom} 的 {pct}。{hint}",
+        "drilldown_title": "明细下钻(前 50 位用户)",
+        "drilldown_caption": "邮箱、手机号为 `users` 表字段;未录入则为空。",
+        "tab_active_today": "今日活跃",
+        "tab_at_risk": "风险流失",
+        "tab_dormant": "沉睡",
+    },
+    "en": {
+        "dashboard_title": "Lyrria Growth Dashboard",
+        "sidebar_lang": "Language / 语言",
+        "sidebar_filters": "Filters",
+        "sidebar_segment": "users.user_segment",
+        "sidebar_school": "School (org_name)",
+        "sidebar_exclude_test": "Exclude users.is_test_user = 1",
+        "sidebar_target_date": "Target date",
+        "sidebar_view_members": "View Community Members",
+        "community_dialog_title": "Community Members",
+        "community_subheader": "Community Members",
+        "community_rule": "Rule: not opted out, and email comes from users.email or from early_community after phone sign-up.",
+        "community_count": "Members Count",
+        "community_empty": "No community members match the current rule.",
+        "dau_trend": "DAU trend",
+        "curr_trend": "CURR trend",
+        "axis_date": "Date",
+        "axis_dau": "DAU",
+        "axis_curr": "CURR (%)",
+        "tooltip_curr": "CURR (%)",
+        "tooltip_prev_day_active": "Prev-day active",
+        "tooltip_retained_today": "Retained today",
+        "tooltip_dau_today": "DAU today",
+        "trend3_caption_1": "30-day trend: left axis = total daily conversation duration (s), right axis = page views.",
+        "trend3_caption_2": "Legend: blue = Conversation duration (s); orange = Lumi shop PV; green = Lumi outfits PV.",
+        "line_duration": "Conversation duration (s)",
+        "line_shop_pv": "Lumi shop PV",
+        "line_outfits_pv": "Lumi outfits PV",
+        "legend_lines": "Lines",
+        "axis_duration": "Conversation duration (s)",
+        "axis_pv_count": "Page views (count)",
+        "tooltip_pv_type": "PV type",
+        "tooltip_pv": "PV",
+        "segments_on": "Segments on {date}",
+        "segment_new": "New",
+        "segment_current": "Current",
+        "segment_reactivated": "Reactivated",
+        "segment_at_risk_7d": "At-risk (7d)",
+        "segment_at_risk_30d": "At-risk (30d)",
+        "segment_dormant_30d": "Dormant (>=30d)",
+        "active_today_composition": "Active today composition",
+        "not_active_today": "Not active today",
+        "metric_inactive_total": "Inactive / Total",
+        "caption_active_comp": "How today's DAU is composed.",
+        "caption_inactive_comp": "Breakdown of users who are not active today (within the current filtered cohort).",
+        "hint_share_dau": "Share of DAU.",
+        "hint_share_inactive": "Share of users not active today.",
+        "caption_of": "{pct} of {denom}. {hint}",
+        "drilldown_title": "Drill-down (first 50 users)",
+        "drilldown_caption": "Email and phone come from the `users` table; blank means missing.",
+        "tab_active_today": "Active today",
+        "tab_at_risk": "At-risk",
+        "tab_dormant": "Dormant",
+    },
+}
+
+
+def get_lang() -> str:
+    selected = st.sidebar.selectbox(I18N["en"]["sidebar_lang"], options=["中文", "English"], index=1)
+    return "zh" if selected == "中文" else "en"
+
+
+lang = get_lang()
+
+
+def t(key: str, **kwargs) -> str:
+    text = I18N.get(lang, I18N["en"]).get(key, key)
+    return text.format(**kwargs) if kwargs else text
+
 # ---- MySQL config from setting.conf ----
 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
 DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
@@ -137,10 +262,10 @@ def read_community_members_df() -> pd.DataFrame:
 
 def _render_community_members_content():
     members_df = read_community_members_df()
-    st.caption("规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。")
-    st.metric("参与成员数", f"{len(members_df):,}")
+    st.caption(t("community_rule"))
+    st.metric(t("community_count"), f"{len(members_df):,}")
     if members_df.empty:
-        st.info("当前没有符合条件的共创社区成员。")
+        st.info(t("community_empty"))
         return
     st.dataframe(
         members_df,
@@ -164,28 +289,53 @@ def _apply_dialog_extra_wide_style():
 
 
 if hasattr(st, "dialog"):
-    @st.dialog("共创社区成员", width="large")
+    @st.dialog(t("community_dialog_title"), width="large")
     def show_community_members_dialog():
         _apply_dialog_extra_wide_style()
         _render_community_members_content()
 else:
     def show_community_members_dialog():
-        st.subheader("共创社区成员")
+        st.subheader(t("community_subheader"))
         _render_community_members_content()
 
-st.title("Lyrria Growth Dashboard")
+st.title(t("dashboard_title"))
 
 # ---- Sidebar ----
-st.sidebar.header("Filters")
+st.sidebar.header(t("sidebar_filters"))
 
 segments = read_df("SELECT DISTINCT user_segment AS seg FROM users WHERE user_segment IS NOT NULL 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()
+org_df = read_df(
+    """
+    SELECT DISTINCT uda.org_id_snapshot AS org_id, o.org_name
+    FROM user_daily_activity uda
+    LEFT JOIN organizations o
+        ON o.org_id COLLATE utf8mb4_unicode_ci = uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
+    WHERE uda.org_id_snapshot IS NOT NULL
+    ORDER BY
+        COALESCE(
+            o.org_name COLLATE utf8mb4_unicode_ci,
+            uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
+        ),
+        uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
+    """
+)
+org_df["org_id"] = org_df["org_id"].astype(str)
+org_map = {
+    row["org_id"]: (str(row["org_name"]).strip() if pd.notna(row["org_name"]) and str(row["org_name"]).strip() else row["org_id"])
+    for _, row in org_df.iterrows()
+}
+orgs = org_df["org_id"].tolist()
 
 segment_default = [s for s in segments if s != "internal"] if segments else []
-segment_sel = st.sidebar.multiselect("users.user_segment", options=segments, default=segment_default)
-org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
+segment_sel = st.sidebar.multiselect(t("sidebar_segment"), options=segments, default=segment_default)
+org_sel = st.sidebar.multiselect(
+    t("sidebar_school"),
+    options=orgs,
+    default=[],
+    format_func=lambda org_id: org_map.get(org_id, org_id),
+)
 
-exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
+exclude_test = st.sidebar.checkbox(t("sidebar_exclude_test"), value=True)
 
 # Date bounds
 max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
@@ -194,14 +344,14 @@ today = date.today()
 
 # UX: 默认选择今天,允许选择今天之前所有日期(若库里最小日期晚于今天,则回退为该最小日期)
 target_day = st.sidebar.date_input(
-    "Target date",
+    t("sidebar_target_date"),
     value=today if today >= min_day else min_day,
     min_value=min_day,
     max_value=today if today >= min_day else min_day,
 )
 
 st.sidebar.markdown("---")
-if st.sidebar.button("查看共创社区成员", use_container_width=True):
+if st.sidebar.button(t("sidebar_view_members"), use_container_width=True):
     show_community_members_dialog()
 
 # ---- Load users (for universe + test filter) ----
@@ -260,7 +410,7 @@ else:
     cohort_universe = universe_users
 
 # ---- Trend (DAU, by day) ----
-st.subheader("DAU trend")
+st.subheader(t("dau_trend"))
 # 最近 30 天(含 target_day);若早于库内最小日期则截断
 trend_start = max(min_day, target_day - timedelta(days=29))
 dau_raw = (
@@ -285,15 +435,15 @@ chart = (
     alt.Chart(trend)
     .mark_line(point=True)
     .encode(
-        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
-        y=alt.Y("dau:Q", axis=alt.Axis(title="DAU")),
+        x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
+        y=alt.Y("dau:Q", axis=alt.Axis(title=t("axis_dau"))),
         tooltip=["day", "dau"],
     )
 )
 st.altair_chart(chart, use_container_width=True)
 
 # ---- Trend (CURR, by day from curr_daily) ----
-st.subheader("CURR trend")
+st.subheader(t("curr_trend"))
 curr_start = max(min_day, target_day - timedelta(days=29))
 curr_df = read_df(
     """
@@ -321,14 +471,14 @@ curr_chart = (
     alt.Chart(curr_plot)
     .mark_line(point=True, color="#f59e0b")
     .encode(
-        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
-        y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title="CURR (%)", format=".2f")),
+        x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
+        y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title=t("axis_curr"), format=".2f")),
         tooltip=[
             "day",
-            alt.Tooltip("curr_rate_pct:Q", title="CURR (%)", format=".2f"),
-            alt.Tooltip("prev_day_active_count:Q", title="Prev-day active"),
-            alt.Tooltip("retained_count:Q", title="Retained today"),
-            alt.Tooltip("total_active_today:Q", title="DAU today"),
+            alt.Tooltip("curr_rate_pct:Q", title=t("tooltip_curr"), format=".2f"),
+            alt.Tooltip("prev_day_active_count:Q", title=t("tooltip_prev_day_active")),
+            alt.Tooltip("retained_count:Q", title=t("tooltip_retained_today")),
+            alt.Tooltip("total_active_today:Q", title=t("tooltip_dau_today")),
         ],
     )
 )
@@ -380,23 +530,37 @@ for col in ["lumi_shop_pv", "lumi_outfits_pv", "total_conversation_duration_seco
         trend3_plot[col] = trend3_plot[col].fillna(0)
 trend3_plot["day"] = trend3_plot["activity_date"].astype(str)
 
-st.caption("30d 走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。三条线:lumi-shop、lumi-outfits、对话时长。")
+st.caption(t("trend3_caption_1"))
+st.caption(t("trend3_caption_2"))
+
+duration_label = t("line_duration")
+shop_label = t("line_shop_pv")
+outfits_label = t("line_outfits_pv")
 
 duration_line = (
     alt.Chart(trend3_plot)
+    .transform_calculate(line_type=f"'{duration_label}'")
     .mark_line(point=True, color="#3b82f6")
     .encode(
-        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
+        x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
         y=alt.Y(
             "total_conversation_duration_seconds:Q",
-            axis=alt.Axis(title="Conversation duration (s)"),
+            axis=alt.Axis(title=t("axis_duration")),
             scale=alt.Scale(domainMin=0),
         ),
+        color=alt.Color(
+            "line_type:N",
+            scale=alt.Scale(
+                domain=[duration_label, shop_label, outfits_label],
+                range=["#3b82f6", "#f59e0b", "#10b981"],
+            ),
+            legend=alt.Legend(title=t("legend_lines")),
+        ),
         tooltip=[
             "day",
-            alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
-            alt.Tooltip("lumi_shop_pv:Q", title="Lumi shop PV", format=",d"),
-            alt.Tooltip("lumi_outfits_pv:Q", title="Lumi outfits PV", format=",d"),
+            alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
+            alt.Tooltip("lumi_shop_pv:Q", title=shop_label, format=",d"),
+            alt.Tooltip("lumi_outfits_pv:Q", title=outfits_label, format=",d"),
         ],
     )
 )
@@ -407,30 +571,32 @@ pv_lines = (
         ["lumi_shop_pv", "lumi_outfits_pv"],
         as_=["pv_type", "pv_count"],
     )
+    .transform_calculate(
+        line_type=f"""
+        datum.pv_type === 'lumi_shop_pv' ? '{shop_label}' : '{outfits_label}'
+        """
+    )
     .mark_line(point=True)
     .encode(
         x=alt.X("day:N"),
         y=alt.Y(
             "pv_count:Q",
-            axis=alt.Axis(title="Page views (count)", orient="right"),
+            axis=alt.Axis(title=t("axis_pv_count"), orient="right"),
             scale=alt.Scale(domainMin=0),
         ),
         color=alt.Color(
-            "pv_type:N",
+            "line_type:N",
             scale=alt.Scale(
-                domain=["lumi_shop_pv", "lumi_outfits_pv"],
-                range=["#f59e0b", "#10b981"],
-            ),
-            legend=alt.Legend(
-                title="PV lines",
-                labelExpr="datum.value === 'lumi_shop_pv' ? 'Lumi shop PV' : 'Lumi outfits PV'",
+                domain=[duration_label, shop_label, outfits_label],
+                range=["#3b82f6", "#f59e0b", "#10b981"],
             ),
+            legend=alt.Legend(title=t("legend_lines")),
         ),
         tooltip=[
             "day",
-            alt.Tooltip("pv_type:N", title="PV type"),
-            alt.Tooltip("pv_count:Q", title="PV", format=",d"),
-            alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
+            alt.Tooltip("pv_type:N", title=t("tooltip_pv_type")),
+            alt.Tooltip("pv_count:Q", title=t("tooltip_pv"), format=",d"),
+            alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
         ],
     )
 )
@@ -501,19 +667,19 @@ dormant_users = {
 }
 
 # ---- Segments visualization (Duolingo-like, no tables) ----
-st.subheader(f"Segments on {d0.isoformat()}")
+st.subheader(t("segments_on", date=d0.isoformat()))
 
 active_buckets = [
-    ("New", len(new_users)),
-    ("Current", len(current_users)),
-    ("Reactivated", len(reactivated_users)),
+    ("segment_new", len(new_users)),
+    ("segment_current", len(current_users)),
+    ("segment_reactivated", len(reactivated_users)),
 ]
 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)),
+    ("segment_at_risk_7d", len(at_risk_wau)),
+    ("segment_at_risk_30d", len(at_risk_mau)),
+    ("segment_dormant_30d", len(dormant_users)),
 ]
 
 def segment_card(label: str, value: int, denom: int, hint: str):
@@ -528,7 +694,7 @@ def segment_card(label: str, value: int, denom: int, hint: str):
         unsafe_allow_html=True,
     )
     st.progress(min(max(pct, 0.0), 1.0))
-    st.caption(f"{pct:.1%} of {denom:,}. {hint}")
+    st.caption(t("caption_of", pct=f"{pct:.1%}", denom=f"{denom:,}", hint=hint))
 
 
 def drilldown_table(user_ids: set, users_df: pd.DataFrame, limit: int = 50) -> pd.DataFrame:
@@ -562,31 +728,31 @@ 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")
+        st.markdown(f"### {t('active_today_composition')}")
     with t2:
         st.metric("DAU", len(active_today))
 
-    st.caption("How today's DAU is composed.")
-    for label, val in active_buckets:
-        segment_card(label, val, active_total, "Share of DAU.")
+    st.caption(t("caption_active_comp"))
+    for label_key, val in active_buckets:
+        segment_card(t(label_key), val, active_total, t("hint_share_dau"))
 
 with right:
     t1, t2 = st.columns([2, 2], vertical_alignment="center")
     with t1:
-        st.markdown("### Not active today")
+        st.markdown(f"### {t('not_active_today')}")
     with t2:
-        st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
+        st.metric(t("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:
-        segment_card(label, val, inactive_total, "Share of users not active today.")
+    st.caption(t("caption_inactive_comp"))
+    for label_key, val in inactive_buckets:
+        segment_card(t(label_key), val, inactive_total, t("hint_share_inactive"))
 
 
 st.markdown("---")
 # ---- Drill down ----
-st.subheader("Drill-down (first 50 users)")
-st.caption("邮箱、手机号为 `users` 表字段;未录入则为空。")
-tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
+st.subheader(t("drilldown_title"))
+st.caption(t("drilldown_caption"))
+tab1, tab2, tab3 = st.tabs([t("tab_active_today"), t("tab_at_risk"), t("tab_dormant")])
 with tab1:
     st.dataframe(
         drilldown_table(active_today, users),

+ 360 - 0
energyStoneStats/stat_student_energy_stones.py

@@ -0,0 +1,360 @@
+#!/usr/bin/env python3
+"""
+按学生名单统计对话能量石:
+- 当前余额:users.conversation_energy_stone_total
+- 累计消费:优先 asset_purchase_logs.cost_energy(按用户聚合后 floor),回退 purchase_orders.total_cost_energy(completed)
+
+名单格式(每行):
+姓名<空白>性别<空白>手机号或邮箱
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import math
+import os
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+import pymysql
+
+
+STUDENTS_RAW = """
+罗美琦	女	19711326711
+龚思彤	女	15159576276
+田野	女	19902066958
+刘可心	女	13212206251
+田萌萌	女	17516593368
+罗婷婷	女	19314452610
+张晋恺	女	13224873003
+徐锦杰	男	13071721013
+王玉丽	女	3909084415@qq.com
+李雯雯	女	15294449145
+谢思娴	女	tdhf_1314@qq.com
+杨曌星	男	13523271668
+冯钰鑫	女	19139663395
+李佳静	女	15538870181
+刘晨	女	15638942669
+周春婷	女	19711317977
+邢恩希	女	15039696935
+雷雯雯	女	17537570801
+梁晨	女	17839964962
+白佳雯	女	18239732780
+鲁玉	女	16692035616
+崔阿米	女	16637083310
+谢梦雅	女	13525607570
+张聪	女	19299697400
+徐悦	女	17508968915
+伍昱霖	女	19723753500
+朱鹏宇	男	18116970476
+冯明慧	女	18526514698
+白雨洁	女	13941827995
+王路路	女	18424092630
+黄嘉	女	19310998940
+吕晶	女	15034931966
+周杰	男	19817605116
+赵文蔚	女	17752217635
+陈特战	男	18876506787
+郭子越	女	15959440103
+刘若愚	女	19713770150
+刘淑元	女	15565391720
+任安媛	女	13938486612
+张雨欣	女	18568987876
+翟耀慈	女	17694823859
+刘叶	女	18837785921
+曹东英	女	15638680120
+王婧睿	女	19836798129
+建灿灿	女	18836955657
+李伟杰	男	13027567593
+杨紫婷	女	2596304351@qq.com
+乔妙	女	18614940378
+蔡缘缘	女	15348856989
+邱琳	女	18682809921
+裴俐宁	女	17699021801
+郝运	女	18522832899
+王靖涵	女	19841716861
+邓祁	男	19577245616
+李玉	女	17537572086
+张彦慧	女	13947518663
+吴晓萌	女	19883397179
+赵籽菲	女	17752190527
+唐静茹	女	13459073038
+张震宇	男	18537388175
+邢艺凡	女	18821550722
+李笑笑	女	13137518291
+曹梦涵	女	17395985731
+杨梦丹	女	17603814211
+涂梦媛	女	13071720579
+程丽阳	女	15837790081
+孙嘉菡	女	13193759767
+何佩佩	女	13213821903
+高嘉瑶	女	13017648500
+范鹏宁	女	17516920283
+平旭冉	男	18739936558
+王梵	女	17329396296
+娄湘雯	女	15537306219
+董璐雅	女	15836245275
+赵艺斐	女	13307572716
+黄小倍	女	17781490609
+杨鑫	女	17311592921
+马晓轩	女	17399518133
+孙靖然	女	18602625361
+姜瑞萌	女	19711325377
+高静	女	17516628069
+贾少华	男	15704905434
+张若瑜	女	13030474653
+陈紫怡	女	13967285960
+王毅丽	女	15309499161
+黄馨滢	女	18350762010
+郭柏硕	男	19943880018
+张佳慧	女	19032356204
+栗紫阳	女	15083066053
+李静茹	女	19339813022
+杨思雨	女	17537572029
+郭佳畅	女	13383879187
+尚子箴	女	19561500219
+王寒雪	女	18437094969
+陈彦茹	女	18790613283
+陈佳欣	女	14748649659
+刘海玲	女	19562672662
+刘天恩	男	15203867407
+吕佳欣	女	18238638738
+郭盈盈	女	17337520895
+洪优雅	女	19839039137
+苏晓涵	女	17733164846
+辜文清	女	15181056902
+张文艺	男	18121555161
+陈亮颖	女	18999217921
+"""
+
+
+@dataclass
+class Student:
+    name: str
+    gender: str
+    contact: str
+
+    @property
+    def is_email(self) -> bool:
+        return "@" in self.contact
+
+    @property
+    def contact_norm(self) -> str:
+        return self.contact.strip().lower() if self.is_email else self.contact.strip()
+
+
+def floor_non_negative(value: object) -> int:
+    if value is None:
+        return 0
+    try:
+        num = float(value)
+    except (TypeError, ValueError):
+        return 0
+    if num <= 0:
+        return 0
+    return int(math.floor(num))
+
+
+def parse_students(raw: str) -> list[Student]:
+    rows: list[Student] = []
+    for ln in raw.strip().splitlines():
+        line = ln.strip()
+        if not line:
+            continue
+        parts = [p for p in re.split(r"\s+", line) if p]
+        if len(parts) < 3:
+            raise ValueError(f"名单行格式错误: {line}")
+        name, gender, contact = parts[0], parts[1], parts[2]
+        rows.append(Student(name=name, gender=gender, contact=contact))
+    return rows
+
+
+def get_db_config() -> dict:
+    return {
+        "host": os.getenv("MYSQL_HOST", "127.0.0.1"),
+        "port": int(os.getenv("MYSQL_PORT", "3306")),
+        "user": os.getenv("MYSQL_USER", "root"),
+        "password": os.getenv("MYSQL_PASSWORD", ""),
+        "database": os.getenv("MYSQL_DATABASE", "Lyrria"),
+        "charset": "utf8mb4",
+        "cursorclass": pymysql.cursors.DictCursor,
+        "autocommit": True,
+    }
+
+
+def fetch_user_rows(cur, students: Iterable[Student]) -> dict[str, dict]:
+    phones = sorted({s.contact_norm for s in students if not s.is_email})
+    emails = sorted({s.contact_norm for s in students if s.is_email})
+
+    matched: dict[str, dict] = {}
+
+    if phones:
+        placeholders = ", ".join(["%s"] * len(phones))
+        sql = (
+            "SELECT id, user_id, username, phone, email, "
+            "conversation_energy_stone_total, crystal_stone_total "
+            f"FROM users WHERE phone IN ({placeholders})"
+        )
+        cur.execute(sql, phones)
+        for row in cur.fetchall():
+            if row.get("phone"):
+                matched[str(row["phone"])] = row
+
+    if emails:
+        placeholders = ", ".join(["%s"] * len(emails))
+        sql = (
+            "SELECT id, user_id, username, phone, email, "
+            "conversation_energy_stone_total, crystal_stone_total "
+            f"FROM users WHERE LOWER(email) IN ({placeholders})"
+        )
+        cur.execute(sql, emails)
+        for row in cur.fetchall():
+            email = row.get("email")
+            if email:
+                matched[email.strip().lower()] = row
+
+    return matched
+
+
+def fetch_spent_map(cur, user_ids: list[str], user_pk_ids: list[int]) -> dict[str, int]:
+    spent_map = {uid: 0 for uid in user_ids}
+    if not user_ids:
+        return spent_map
+
+    uid_placeholders = ", ".join(["%s"] * len(user_ids))
+    id_placeholders = ", ".join(["%s"] * len(user_pk_ids)) if user_pk_ids else ""
+
+    # 1) 优先流水表 asset_purchase_logs
+    try:
+        where_clauses = [f"CAST(apl.user_id AS CHAR) IN ({uid_placeholders})"]
+        params: list[object] = list(user_ids)
+        if user_pk_ids:
+            where_clauses.append(f"apl.user_id IN ({id_placeholders})")
+            params.extend(user_pk_ids)
+        sql = (
+            "SELECT CAST(u.user_id AS CHAR) AS uid, "
+            "FLOOR(COALESCE(SUM(apl.cost_energy), 0)) AS spent_energy "
+            "FROM asset_purchase_logs apl "
+            "JOIN users u ON (CAST(apl.user_id AS CHAR) = CAST(u.user_id AS CHAR) OR apl.user_id = u.id) "
+            f"WHERE {' OR '.join(where_clauses)} "
+            "GROUP BY u.user_id"
+        )
+        cur.execute(sql, params)
+        rows = cur.fetchall()
+        for row in rows:
+            uid = str(row["uid"])
+            spent_map[uid] = floor_non_negative(row.get("spent_energy"))
+        return spent_map
+    except Exception:
+        pass
+
+    # 2) 回退订单表 purchase_orders(仅 completed)
+    try:
+        where_clauses = [f"CAST(po.user_id AS CHAR) IN ({uid_placeholders})"]
+        params = list(user_ids)
+        if user_pk_ids:
+            where_clauses.append(f"po.user_id IN ({id_placeholders})")
+            params.extend(user_pk_ids)
+        sql = (
+            "SELECT CAST(u.user_id AS CHAR) AS uid, "
+            "FLOOR(COALESCE(SUM(po.total_cost_energy), 0)) AS spent_energy "
+            "FROM purchase_orders po "
+            "JOIN users u ON (CAST(po.user_id AS CHAR) = CAST(u.user_id AS CHAR) OR po.user_id = u.id) "
+            "WHERE po.status = 'completed' "
+            f"AND ({' OR '.join(where_clauses)}) "
+            "GROUP BY u.user_id"
+        )
+        cur.execute(sql, params)
+        rows = cur.fetchall()
+        for row in rows:
+            uid = str(row["uid"])
+            spent_map[uid] = floor_non_negative(row.get("spent_energy"))
+        return spent_map
+    except Exception:
+        return spent_map
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="统计学生能量石与消费")
+    parser.add_argument(
+        "--output",
+        default="energyStoneStats/student_energy_stones_report.csv",
+        help="输出 CSV 路径",
+    )
+    args = parser.parse_args()
+
+    students = parse_students(STUDENTS_RAW)
+    out_path = Path(args.output).resolve()
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+
+    db_conf = get_db_config()
+
+    try:
+        conn = pymysql.connect(**db_conf)
+    except Exception as exc:
+        print(f"数据库连接失败: {exc}", file=sys.stderr)
+        return 1
+
+    try:
+        with conn.cursor() as cur:
+            user_map = fetch_user_rows(cur, students)
+            found_rows = [row for row in user_map.values()]
+            uid_list = [str(r["user_id"]) for r in found_rows if r.get("user_id")]
+            id_list = [int(r["id"]) for r in found_rows if r.get("id") is not None]
+            spent_map = fetch_spent_map(cur, uid_list, id_list)
+    finally:
+        conn.close()
+
+    headers = [
+        "姓名",
+        "性别",
+        "注册联系方式",
+        "是否匹配到账号",
+        "平台用户名",
+        "user_id",
+        "当前对话能量石",
+        "累计消费对话能量石",
+    ]
+
+    matched_count = 0
+    with out_path.open("w", encoding="utf-8-sig", newline="") as f:
+        writer = csv.writer(f)
+        writer.writerow(headers)
+
+        for s in students:
+            row = user_map.get(s.contact_norm)
+            if not row:
+                writer.writerow([s.name, s.gender, s.contact, "否", "", "", 0, 0])
+                continue
+
+            matched_count += 1
+            conv = int(row.get("conversation_energy_stone_total") or 0)
+            uid = str(row["user_id"])
+            spent_energy = int(spent_map.get(uid, 0))
+
+            writer.writerow(
+                [
+                    s.name,
+                    s.gender,
+                    s.contact,
+                    "是",
+                    row.get("username") or "",
+                    uid,
+                    conv,
+                    spent_energy,
+                ]
+            )
+
+    print(f"已输出统计结果: {out_path}")
+    print(f"名单总人数: {len(students)}")
+    print(f"匹配成功: {matched_count}")
+    print(f"未匹配: {len(students) - matched_count}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 6 - 6
frontend/src/constants/audioLevels.ts

@@ -4,16 +4,16 @@
  * - SFX:playOssSfx / 按钮 / UI 短音(HTMLAudioElement)
  * - TTS:AudioPlayer(Web Audio,独立增益链,通常显著高于 BGM)
  *
- * BGM 基准:约 −40 dBFS(相对 volume=1),再按曲目乘系数便于 SFX 清晰可辨。
+ * BGM 基准:相对 HTMLAudioElement.volume 的线性比例(0~1)。
  * HTMLAudioElement.volume 为线性增益:10^(dB/20)。
  * 约束:SFX_VOLUME 须始终大于各 BGM 音量。
  */
-const BGM_BASE = 10 ** (-40 / 20); // ≈ 0.01
+const BGM_BASE = 1;
 
-/** System 页循环曲:在基准上乘 0.4 */
-export const SYSTEM_BGM_VOLUME = BGM_BASE * 0.4;
+/** System 页循环曲:100% */
+export const SYSTEM_BGM_VOLUME = BGM_BASE;
 
-/** Chatbot Scene 循环曲:在基准上乘 0.2 */
-export const SCENE_BGM_VOLUME = BGM_BASE * 0.2;
+/** Chatbot Scene 循环曲:100% */
+export const SCENE_BGM_VOLUME = BGM_BASE;
 
 export const SFX_VOLUME = 1;

+ 6 - 6
frontend/src/index.css

@@ -48,7 +48,7 @@ body {
   /* 防止边距导致的滚动 */
   margin: 0;
   padding: 0;
-  /* 全局背景:在宽度超过 600px 时,左右区域仍然使用应用背景而不是纯白色 */
+  /* 全局背景:在宽度超过 500px 时,左右区域仍然使用应用背景而不是纯白色 */
   background-color: #E8E8E8;
   background-image: var(--oss-chatbot-bg-url, url("https://www.lyrria.com/img/chatbot-bg.svg"));
   background-size: cover;
@@ -72,8 +72,8 @@ body[data-too-small="true"] {
 }
 
 #root {
-  width: min(100%, 600PX);
-  max-width: 600PX;
+  width: min(100%, 500PX);
+  max-width: 500PX;
   height: 100%;
   /* 禁止所有滚动 */
   overflow: hidden;
@@ -107,8 +107,8 @@ body[data-too-small="true"] {
 
 /* 内容卡片 - 基础样式(移动端) */
 .content-card {
-  width: min(100%, 600PX);
-  max-width: 600PX;
+  width: min(100%, 500PX);
+  max-width: 500PX;
   height: 100%;
   margin: 0 16PX;
   /* background: #F5F3F0; */
@@ -228,7 +228,7 @@ img {
 /* 响应式工具类 */
 .responsive-container {
   width: 100%;
-  max-width: min(100%, 600PX);
+  max-width: min(100%, 500PX);
   margin: 0 auto;
   padding: 0 clamp(16px, 4vw, 32px);
 }

+ 198 - 52
frontend/src/pages/ChatBotPage.tsx

@@ -48,6 +48,7 @@ import {
   getApiHeaders,
 } from '@/utils/api';
 import { px2vmin } from '@/utils/viewport';
+import { buildDecorGridLayoutCss } from '@/utils/decorGridLayout';
 import type { Message, EnergyStoneSpawnMessage, GreetingContextMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
@@ -585,6 +586,113 @@ export const ChatBotPage = () => {
   const emotionGifPlayedThisRoundRef = useRef<boolean>(false);
   // 当前正在累积字幕的 requestId;同一次对话多段音频的文字做拼接,新请求时重置
   const subtitleAccumulatorRequestIdRef = useRef<string | null>(null);
+  // 首句/切换话题首句:在场景未解析完成前先缓存音频,避免「声音已播但形象未出现」
+  const pendingGreetingAudioRef = useRef<
+    Map<string, Array<{ bytes: Uint8Array; sampleRate: number }>>
+  >(new Map());
+  const pendingGreetingAudioEndRef = useRef<Set<string>>(new Set());
+  const [pendingGreetingFlushTick, setPendingGreetingFlushTick] = useState(0);
+
+  // 中间 AnimaZone 双层图片加载状态:严格按“当前可见层”决定是否允许首句播放
+  const [defaultLayerLoaded, setDefaultLayerLoaded] = useState(false);
+  const [conversationLayerLoaded, setConversationLayerLoaded] = useState(false);
+  const animaAvatarLoadFallbackTimerRef = useRef<number | null>(null);
+
+  // 是否需要“等待中间形象加载”才允许首句播放:只有在中间形象会显示的情况下才需要等待
+  const shouldGateGreetingAudioByAnimaAvatar =
+    hasGreetingScenarioResolved &&
+    !(greetingContext?.scene && greetingContext.scene !== 'default' && !specialSceneAvatarDismissed);
+
+  const isConversationLayerVisible = currentState === 'sending' || currentState === 'playing';
+  const visibleAnimaLayerLoaded = isConversationLayerVisible ? conversationLayerLoaded : defaultLayerLoaded;
+
+  // 当需要等待中间形象时:进入该模式即重置双层 loaded,并启动短超时兜底(避免图片异常导致永远不播)
+  useEffect(() => {
+    if (!shouldGateGreetingAudioByAnimaAvatar) {
+      setDefaultLayerLoaded(true);
+      setConversationLayerLoaded(true);
+      if (animaAvatarLoadFallbackTimerRef.current) {
+        clearTimeout(animaAvatarLoadFallbackTimerRef.current);
+        animaAvatarLoadFallbackTimerRef.current = null;
+      }
+      return;
+    }
+
+    setDefaultLayerLoaded(false);
+    setConversationLayerLoaded(false);
+    if (animaAvatarLoadFallbackTimerRef.current) {
+      clearTimeout(animaAvatarLoadFallbackTimerRef.current);
+      animaAvatarLoadFallbackTimerRef.current = null;
+    }
+    // 兜底:1.5s 内图片仍未回调则放行,避免卡死
+    animaAvatarLoadFallbackTimerRef.current = window.setTimeout(() => {
+      animaAvatarLoadFallbackTimerRef.current = null;
+      setDefaultLayerLoaded(true);
+      setConversationLayerLoaded(true);
+    }, 1500);
+
+    return () => {
+      if (animaAvatarLoadFallbackTimerRef.current) {
+        clearTimeout(animaAvatarLoadFallbackTimerRef.current);
+        animaAvatarLoadFallbackTimerRef.current = null;
+      }
+    };
+  }, [shouldGateGreetingAudioByAnimaAvatar]);
+
+  const greetingAudioUnblocked =
+    hasGreetingScenarioResolved &&
+    (!shouldGateGreetingAudioByAnimaAvatar || visibleAnimaLayerLoaded);
+
+  // 一旦首句场景解析完成(允许渲染形象),再将首句缓存音频在下一帧喂给播放器并开始播放
+  useEffect(() => {
+    if (!greetingAudioUnblocked) return;
+    const pending = pendingGreetingAudioRef.current;
+    if (pending.size === 0) return;
+
+    // 双 RAF:尽量保证本帧 state 提交 + DOM 渲染完成后再启动音频
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        const entries = Array.from(pending.entries());
+        entries.forEach(([requestId, chunks]) => {
+          const player = playerRef.current;
+          if (!player) {
+            // 播放器尚未就绪:保留缓存,等待后续 tick 重试,绝不丢首句音频
+            return;
+          }
+          if (chunks.length === 0) {
+            pending.delete(requestId);
+            pendingGreetingAudioEndRef.current.delete(requestId);
+            return;
+          }
+
+          if (useAppStore.getState().currentState !== 'playing') {
+            setState('playing');
+          }
+          performanceLogger.recordPlaybackStart(requestId);
+
+          chunks.forEach((chunk, idx) => {
+            const skipReset = idx > 0 || streamStartedForRequestIdRef.current === requestId;
+            player.addChunk(chunk.bytes, chunk.sampleRate || 24000, { skipStreamReset: skipReset });
+            if (!skipReset) streamStartedForRequestIdRef.current = requestId;
+          });
+
+          if (pendingGreetingAudioEndRef.current.has(requestId)) {
+            responseCompleteRequestIdRef.current = requestId;
+            performanceLogger.recordLastAudio(requestId);
+            player.markStreamEnd();
+            pendingGreetingAudioEndRef.current.delete(requestId);
+            isGreetingActiveRef.current = false;
+            if (requestId.startsWith('greeting_')) greetingRequestIdRef.current = null;
+          } else {
+            // 标记当前处于首句播放阶段,便于后续开始录音时精确打断
+            isGreetingActiveRef.current = true;
+          }
+
+          pending.delete(requestId);
+        });
+      });
+    });
+  }, [greetingAudioUnblocked, pendingGreetingFlushTick, setState]);
   const timingRef = useRef({
     recordStart: 0,
     recordEnd: 0,
@@ -2232,6 +2340,7 @@ export const ChatBotPage = () => {
           reset({ keepRecognizedText: true }); // 保留字幕内容,仅在下一次点击录音时清空
         });
         playerRef.current = player;
+        setPendingGreetingFlushTick((n) => n + 1);
 
         // 初始化 WebSocket 前要求已有有效会话(老用户先登录再使用,不再创建匿名用户)
         if (!hasValidSession()) {
@@ -2593,44 +2702,63 @@ export const ChatBotPage = () => {
       case 'audio_response':
         // 🔥 首句/切换话题首句:直接处理,不检查状态和 activeRequestId
         if (isGreetingOrTopicFirstLine) {
-          // 首句/话题首句音频:自动播放
-          if (currentState !== 'playing') {
-            const now = Date.now();
-            emotionGifShownForStreamRef.current = false; // 新流开始,允许本段显示情绪 GIF
-            debugLog(`[音频响应][首句] [${requestId.substring(0, 8)}] 首次音频块,进入 playing 状态`, {
+          // 首句/话题首句音频:为保证「形象出现后才播放」,在场景未解析完成前先缓存音频
+          const now = Date.now();
+          emotionGifShownForStreamRef.current = false; // 新流开始,允许本段显示情绪 GIF
+          // 同一 request 一旦已经开始播,后续 chunk 必须直通,避免“首句播了一句后被门控再次拦截”
+          const hasStreamStarted = streamStartedForRequestIdRef.current === requestId;
+          const canPlayGreetingChunkNow = greetingAudioUnblocked || hasStreamStarted;
+
+          if (message.data) {
+            const audioData = atob(message.data);
+            const audioBytes = new Uint8Array(audioData.length);
+            for (let i = 0; i < audioData.length; i++) {
+              audioBytes[i] = audioData.charCodeAt(i);
+            }
+
+            // 首次音频到达打点(不代表已开始播放;recordFirstAudio 内部会去重)
+            debugLog(`[音频响应][首句] [${requestId.substring(0, 8)}] 音频块到达(等待形象渲染后播放)`, {
               messageTimestamp: message.timestamp,
               clientNow: now,
               delayMs: typeof message.timestamp === 'number' ? now - message.timestamp : null,
             });
             performanceLogger.recordFirstAudio(requestId, message.timestamp);
-            performanceLogger.recordPlaybackStart(requestId);
-            setState('playing');
-          }
-          // 标记当前处于首句播放阶段,便于后续开始录音时精确打断
-          isGreetingActiveRef.current = true;
 
-          if (message.data && playerRef.current) {
-            if (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current !== requestId) {
-              responseCompleteRequestIdRef.current = null;
-            }
-            const audioData = atob(message.data);
-            const audioBytes = new Uint8Array(audioData.length);
-            for (let i = 0; i < audioData.length; i++) {
-              audioBytes[i] = audioData.charCodeAt(i);
+            if (!canPlayGreetingChunkNow || !playerRef.current) {
+              const arr = pendingGreetingAudioRef.current.get(requestId) || [];
+              arr.push({ bytes: audioBytes, sampleRate: message.sampleRate || 24000 });
+              pendingGreetingAudioRef.current.set(requestId, arr);
+              setPendingGreetingFlushTick((n) => n + 1);
+            } else if (playerRef.current) {
+              // 场景已解析:可直接播放
+              if (currentState !== 'playing') {
+                performanceLogger.recordPlaybackStart(requestId);
+                setState('playing');
+              }
+              // 标记当前处于首句播放阶段,便于后续开始录音时精确打断
+              isGreetingActiveRef.current = true;
+              if (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current !== requestId) {
+                responseCompleteRequestIdRef.current = null;
+              }
+              const skipReset =
+                streamStartedForRequestIdRef.current === requestId ||
+                (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current === requestId);
+              playerRef.current.addChunk(audioBytes, message.sampleRate || 24000, { skipStreamReset: skipReset });
+              if (!skipReset) streamStartedForRequestIdRef.current = requestId;
             }
-            // 同一 request 只对「首包」触发 onStart,后续包(含第二段、第三段音频)一律 skipStreamReset
-            const skipReset =
-              streamStartedForRequestIdRef.current === requestId ||
-              (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current === requestId);
-            playerRef.current.addChunk(audioBytes, message.sampleRate || 24000, { skipStreamReset: skipReset });
-            if (!skipReset) streamStartedForRequestIdRef.current = requestId;
           }
+
           if (message.isLast) {
-            responseCompleteRequestIdRef.current = requestId;
-            performanceLogger.recordLastAudio(requestId);
-            playerRef.current?.markStreamEnd();
-            isGreetingActiveRef.current = false;
-            if (isGreetingMessage) greetingRequestIdRef.current = null;
+            if (!canPlayGreetingChunkNow || !playerRef.current) {
+              pendingGreetingAudioEndRef.current.add(requestId);
+              setPendingGreetingFlushTick((n) => n + 1);
+            } else {
+              responseCompleteRequestIdRef.current = requestId;
+              performanceLogger.recordLastAudio(requestId);
+              playerRef.current?.markStreamEnd();
+              isGreetingActiveRef.current = false;
+              if (isGreetingMessage) greetingRequestIdRef.current = null;
+            }
           }
           break;
         }
@@ -3109,6 +3237,17 @@ export const ChatBotPage = () => {
   // 5×4 区域距界面顶部为 0;水平方向在幕布舞台内居中(见下方 stage 内列容器)
   const gridOriginY = 0;
   const gridOriginX = 0;
+  const decorGridCss = useMemo(
+    () =>
+      buildDecorGridLayoutCss({
+        gridCols,
+        wallRows: 3,
+        floorRows: 2,
+        baseCellWidth: cellWidth,
+        baseCellHeight: cellHeight,
+      }),
+    [gridCols, cellWidth, cellHeight],
+  );
 
   const defaultGifUrl = avatarGifSet?.default ?? '';
   // 双 img 固定角色:default 层永远 default;对话层 playing 时播 emotion/talking
@@ -3172,7 +3311,6 @@ export const ChatBotPage = () => {
               src={getOssUrl(activeBackdropImageName)}
               alt=""
               className="absolute"
-              fetchPriority="low"
               loading="lazy"
               draggable={false}
               style={{
@@ -3189,11 +3327,10 @@ export const ChatBotPage = () => {
         <div
           className="absolute pointer-events-none"
           style={{
-            left: '50%',
-            transform: 'translateX(-50%)',
-            width: px2vmin(gridCols * cellWidth),
-            top: 0,
-            bottom: 0,
+            left: decorGridCss.gridLeft,
+            top: decorGridCss.gridTop,
+            width: decorGridCss.gridWidth,
+            height: decorGridCss.gridHeight,
             zIndex: 1,
           }}
         >
@@ -3216,14 +3353,13 @@ export const ChatBotPage = () => {
                 src={getOssUrl(item.image_name_snapshot)}
                 alt=""
                 className="absolute pointer-events-none"
-                fetchPriority="low"
                 loading="lazy"
                 draggable={false}
                 style={{
-                  width: px2vmin(widthPx),
-                  height: px2vmin(heightPx),
-                  left: px2vmin(leftPx),
-                  top: px2vmin(topPx),
+                  width: decorGridCss.toW(widthPx),
+                  height: decorGridCss.toH(heightPx),
+                  left: decorGridCss.toX(leftPx),
+                  top: decorGridCss.toY(topPx),
                   // 装饰品层级:基于数据库配置的 z-index,渲染时统一 +1
                   // (原逻辑会强制最小值为 3,导致 DB 的低层级都被钳到同一层)
                   zIndex: (item.z_index ?? 0) + 1,
@@ -3275,10 +3411,10 @@ export const ChatBotPage = () => {
                     <div
                       className="absolute pointer-events-none"
                       style={{
-                        left: px2vmin(anchorLeft),
-                        top: px2vmin(anchorTop),
-                        width: px2vmin(anchorWidth),
-                        height: px2vmin(anchorHeight),
+                        left: decorGridCss.toX(anchorLeft),
+                        top: decorGridCss.toY(anchorTop),
+                        width: decorGridCss.toW(anchorWidth),
+                        height: decorGridCss.toH(anchorHeight),
                         zIndex: avatarZ,
                       }}
                     >
@@ -3292,8 +3428,8 @@ export const ChatBotPage = () => {
                           position: 'absolute',
                           left: '-15%',
                           bottom: '-50%',
-                          width: px2vmin(gifSize),
-                          height: px2vmin(gifSize),
+                          width: decorGridCss.toW(gifSize),
+                          height: decorGridCss.toH(gifSize),
                           objectFit: 'contain',
                           objectPosition: 'left bottom',
                         }}
@@ -3313,10 +3449,10 @@ export const ChatBotPage = () => {
                     className="absolute pointer-events-none"
                     loading="lazy"
                     style={{
-                      width: px2vmin(gifSize),
-                      height: px2vmin(gifSize),
-                      left: px2vmin(gifLeft),
-                      top: px2vmin(gifTop),
+                      width: decorGridCss.toW(gifSize),
+                      height: decorGridCss.toH(gifSize),
+                      left: decorGridCss.toX(gifLeft),
+                      top: decorGridCss.toY(gifTop),
                       zIndex: avatarZ,
                       objectFit: 'contain',
                       objectPosition: 'center bottom',
@@ -3672,7 +3808,12 @@ export const ChatBotPage = () => {
             alt={currentAvatarAlt}
             loading="eager"
             decoding="async"
-            fetchPriority="high"
+            onLoad={() => {
+              setDefaultLayerLoaded(true);
+            }}
+            onError={() => {
+              setDefaultLayerLoaded(true);
+            }}
             style={{
               position: 'absolute',
               inset: 0,
@@ -3689,7 +3830,12 @@ export const ChatBotPage = () => {
             alt={currentAvatarAlt}
             loading="eager"
             decoding="async"
-            fetchPriority="high"
+            onLoad={() => {
+              setConversationLayerLoaded(true);
+            }}
+            onError={() => {
+              setConversationLayerLoaded(true);
+            }}
             style={{
               position: 'absolute',
               inset: 0,

+ 24 - 14
frontend/src/pages/LumiOutfitsPage.tsx

@@ -3,6 +3,7 @@ import type { CSSProperties } from 'react';
 import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
+import { buildDecorGridLayoutCss } from '@/utils/decorGridLayout';
 import { getOssUrl } from '@/utils/oss';
 import { playOssSfx } from '@/utils/sfxPlay';
 import { wrapTrackedClick } from '@/utils/tracking';
@@ -392,6 +393,17 @@ export const LumiOutfitsPage = () => {
   const gridOriginY = 0;
   /** 5×4 在幕布层内水平居中,槽位相对网格左上角 */
   const gridOriginX = 0;
+  const decorGridCss = useMemo(
+    () =>
+      buildDecorGridLayoutCss({
+        gridCols,
+        wallRows: 3,
+        floorRows: 2,
+        baseCellWidth: cellWidth,
+        baseCellHeight: cellHeight,
+      }),
+    [gridCols, cellWidth, cellHeight],
+  );
 
   const furnitureEquipPreviewRect = useMemo(() => {
     if (!furnitureEquipPreview) return null;
@@ -612,11 +624,10 @@ export const LumiOutfitsPage = () => {
           <div
             className="absolute pointer-events-none"
             style={{
-              left: '50%',
-              transform: 'translateX(-50%)',
-              width: px2vmin(gridCols * cellWidth),
-              top: 0,
-              bottom: 0,
+              left: decorGridCss.gridLeft,
+              top: decorGridCss.gridTop,
+              width: decorGridCss.gridWidth,
+              height: decorGridCss.gridHeight,
               zIndex: 1,
             }}
           >
@@ -642,10 +653,10 @@ export const LumiOutfitsPage = () => {
                   loading="lazy"
                   draggable={false}
                   style={{
-                    width: px2vmin(widthPx),
-                    height: px2vmin(heightPx),
-                    left: px2vmin(leftPx),
-                    top: px2vmin(topPx),
+                    width: decorGridCss.toW(widthPx),
+                    height: decorGridCss.toH(heightPx),
+                    left: decorGridCss.toX(leftPx),
+                    top: decorGridCss.toY(topPx),
                     // 装饰品层级:基于数据库配置的 z-index,渲染时统一 +1
                     zIndex: (item.z_index ?? 0) + 1,
                     objectFit: 'contain',
@@ -667,13 +678,12 @@ export const LumiOutfitsPage = () => {
                   loading="eager"
                   draggable={false}
                   style={{
-                    width: px2vmin(furnitureEquipPreviewRect.widthPx),
-                    height: px2vmin(furnitureEquipPreviewRect.heightPx),
-                    left: px2vmin(furnitureEquipPreviewRect.leftPx),
-                    top: px2vmin(furnitureEquipPreviewRect.topPx),
+                    width: decorGridCss.toW(furnitureEquipPreviewRect.widthPx),
+                    height: decorGridCss.toH(furnitureEquipPreviewRect.heightPx),
+                    left: decorGridCss.toX(furnitureEquipPreviewRect.leftPx),
+                    top: decorGridCss.toY(furnitureEquipPreviewRect.topPx),
                     zIndex: furnitureEquipPreviewRect.zIndex,
                     objectFit: 'contain',
-                    opacity: 0.88,
                   }}
                 />
               </>

+ 24 - 14
frontend/src/pages/LumiShopPage.tsx

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
+import { buildDecorGridLayoutCss } from '@/utils/decorGridLayout';
 import { getOssUrl } from '@/utils/oss';
 import { playOssSfx } from '@/utils/sfxPlay';
 import { wrapTrackedClick } from '@/utils/tracking';
@@ -411,6 +412,17 @@ export const LumiShopPage = () => {
   const gridOriginY = 0;
   /** 5×4 区整体在幕布层水平居中,槽位坐标相对网格左上角 */
   const gridOriginX = 0;
+  const decorGridCss = useMemo(
+    () =>
+      buildDecorGridLayoutCss({
+        gridCols,
+        wallRows: 3,
+        floorRows: 2,
+        baseCellWidth: cellWidth,
+        baseCellHeight: cellHeight,
+      }),
+    [gridCols, cellWidth, cellHeight],
+  );
   const furnitureEquipPreviewRect = useMemo(() => {
     if (!furnitureEquipPreview) return null;
     return computeChatbotSlotRectPx(
@@ -862,11 +874,10 @@ export const LumiShopPage = () => {
           <div
             className="absolute pointer-events-none"
             style={{
-              left: '50%',
-              transform: 'translateX(-50%)',
-              width: px2vmin(gridCols * cellWidth),
-              top: 0,
-              bottom: 0,
+              left: decorGridCss.gridLeft,
+              top: decorGridCss.gridTop,
+              width: decorGridCss.gridWidth,
+              height: decorGridCss.gridHeight,
               zIndex: 1,
             }}
           >
@@ -892,10 +903,10 @@ export const LumiShopPage = () => {
                 loading="lazy"
                 draggable={false}
                 style={{
-                  width: px2vmin(widthPx),
-                  height: px2vmin(heightPx),
-                  left: px2vmin(leftPx),
-                  top: px2vmin(topPx),
+                  width: decorGridCss.toW(widthPx),
+                  height: decorGridCss.toH(heightPx),
+                  left: decorGridCss.toX(leftPx),
+                  top: decorGridCss.toY(topPx),
                   // 装饰品层级:基于数据库配置的 z-index,渲染时统一 +1
                   zIndex: (item.z_index ?? 0) + 1,
                   objectFit: 'contain',
@@ -914,13 +925,12 @@ export const LumiShopPage = () => {
               loading="eager"
               draggable={false}
               style={{
-                width: px2vmin(furnitureEquipPreviewRect.widthPx),
-                height: px2vmin(furnitureEquipPreviewRect.heightPx),
-                left: px2vmin(furnitureEquipPreviewRect.leftPx),
-                top: px2vmin(furnitureEquipPreviewRect.topPx),
+                width: decorGridCss.toW(furnitureEquipPreviewRect.widthPx),
+                height: decorGridCss.toH(furnitureEquipPreviewRect.heightPx),
+                left: decorGridCss.toX(furnitureEquipPreviewRect.leftPx),
+                top: decorGridCss.toY(furnitureEquipPreviewRect.topPx),
                 zIndex: furnitureEquipPreviewRect.zIndex,
                 objectFit: 'contain',
-                opacity: 0.88,
               }}
             />
           ) : null}

+ 8 - 8
frontend/src/styles/chatbot-fix copy.css

@@ -123,13 +123,13 @@ body[data-too-small="true"] .chatbot-bg,
 /* 正式能量石缩小动画 - 从 48px 缩小到 4.8px (10%) */
 @keyframes energyStoneShrink {
   0% {
-    width: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
-    height: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    width: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
     opacity: 1 !important;
   }
   100% {
-    width: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
-    height: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    width: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
     opacity: 0 !important;
   }
 }
@@ -137,13 +137,13 @@ body[data-too-small="true"] .chatbot-bg,
 /* 正式能量石光晕缩小动画 - 从 92px 缩小到 9.2px (10%) */
 @keyframes energyStoneHintShrink {
   0% {
-    width: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
-    height: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    width: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
     opacity: 1 !important;
   }
   100% {
-    width: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
-    height: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    width: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
     opacity: 0 !important;
   }
 }

+ 8 - 8
frontend/src/styles/chatbot-fix.css

@@ -119,13 +119,13 @@ body[data-too-small="true"] .chatbot-bg,
 /* 正式能量石缩小动画 - 保持尺寸不变,只做渐隐 */
 @keyframes energyStoneShrink {
   0% {
-    width: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
-    height: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    width: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
     opacity: 1 !important;
   }
   100% {
-    width: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
-    height: min(calc(48 * min(100vw, 600PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    width: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 500PX) / 393), calc(5.633802816901408 * var(--vh, 1vh))) !important;
     opacity: 0 !important;
   }
 }
@@ -133,13 +133,13 @@ body[data-too-small="true"] .chatbot-bg,
 /* 正式能量石光晕缩小动画 - 保持尺寸不变,只做渐隐 */
 @keyframes energyStoneHintShrink {
   0% {
-    width: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
-    height: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    width: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
     opacity: 1 !important;
   }
   100% {
-    width: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
-    height: min(calc(92 * min(100vw, 600PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    width: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 500PX) / 393), calc(10.7981220657277 * var(--vh, 1vh))) !important;
     opacity: 0 !important;
   }
 }

+ 73 - 0
frontend/src/utils/decorGridLayout.ts

@@ -0,0 +1,73 @@
+/**
+ * 与 px2vw 保持一致:横向按 500PX 封顶,避免超宽屏放大导致错位。
+ * 注意这里用 500PX(大写)是为了避免被 px-to-viewport 插件转换。
+ */
+const EFFECTIVE_VW_EXPR = 'min(100vw, 500PX)';
+const COVER_SCALE_EXPR = `max(calc(${EFFECTIVE_VW_EXPR} / 393), calc(100dvh / 852))`;
+
+export interface DecorGridLayoutCssConfig {
+  designWidth?: number;
+  designHeight?: number;
+  splitDesignY?: number;
+  gridCols: number;
+  wallRows: number;
+  floorRows: number;
+  baseCellWidth: number;
+  baseCellHeight: number;
+}
+
+export interface DecorGridLayoutCss {
+  gridLeft: string;
+  gridTop: string;
+  gridWidth: string;
+  gridHeight: string;
+  cellSize: string;
+  toX: (valueInBasePx: number) => string;
+  toY: (valueInBasePx: number) => string;
+  toW: (valueInBasePx: number) => string;
+  toH: (valueInBasePx: number) => string;
+}
+
+export function buildDecorGridLayoutCss(config: DecorGridLayoutCssConfig): DecorGridLayoutCss {
+  const designWidth = config.designWidth ?? 393;
+  const designHeight = config.designHeight ?? 852;
+  const splitDesignY = config.splitDesignY ?? 288;
+  const renderedWidth = `calc(${designWidth} * ${COVER_SCALE_EXPR})`;
+  const renderedHeight = `calc(${designHeight} * ${COVER_SCALE_EXPR})`;
+  const offsetX = `calc((${EFFECTIVE_VW_EXPR} - ${renderedWidth}) / 2)`;
+  const offsetY = `calc((100dvh - ${renderedHeight}) / 2)`;
+  const wallHeight = `calc(${splitDesignY} * ${COVER_SCALE_EXPR})`;
+  /**
+   * 关键:cellSize 必须按「可视区域」计算,而不是按渲染后的全尺寸(含被 cover 裁掉的部分)。
+   * - 宽屏时,背景会按宽度放大,上下被裁;可视墙高会变小 → cellSize 变小 → 墙面家具不会压到地面。
+   */
+  const splitYVisible = `calc(${offsetY} + ${wallHeight})`;
+  const wallVisibleHeight = splitYVisible;
+  const floorVisibleHeight = `calc(100dvh - ${splitYVisible})`;
+  const cellSize = [
+    `calc(${EFFECTIVE_VW_EXPR} / ${Math.max(config.gridCols, 1)})`,
+    `calc(${wallVisibleHeight} / ${Math.max(config.wallRows, 1)})`,
+    `calc(${floorVisibleHeight} / ${Math.max(config.floorRows, 1)})`,
+  ];
+  const cellSizeExpr = `min(${cellSize.join(', ')})`;
+  const gridWidth = `calc(${config.gridCols} * ${cellSizeExpr})`;
+  const gridHeight = `calc(${config.wallRows + config.floorRows} * ${cellSizeExpr})`;
+  const gridLeft = `calc(${offsetX} + (${renderedWidth} - ${gridWidth}) / 2)`;
+  const gridTop = `calc(${splitYVisible} - ${config.wallRows} * ${cellSizeExpr})`;
+  const toX = (valueInBasePx: number) =>
+    `calc(${valueInBasePx} * ${cellSizeExpr} / ${Math.max(config.baseCellWidth, 1)})`;
+  const toY = (valueInBasePx: number) =>
+    `calc(${valueInBasePx} * ${cellSizeExpr} / ${Math.max(config.baseCellHeight, 1)})`;
+
+  return {
+    gridLeft,
+    gridTop,
+    gridWidth,
+    gridHeight,
+    cellSize: cellSizeExpr,
+    toX,
+    toY,
+    toW: toX,
+    toH: toY,
+  };
+}

+ 2 - 2
frontend/src/utils/viewport.ts

@@ -26,7 +26,7 @@ const DESIGN_SPECS: Record<ViewportDesign, { width: number; height: number }> =
 
 /**
  * px 转 vw 辅助函数(用于内联样式)
- * - 视口宽度 > 600px 时按 600px 封顶
+ * - 视口宽度 > 500px 时按 500px 封顶
  * - 使用 --vw(visualViewport)解决曲面屏问题
  *
  * @param px 设计稿上的 px 值
@@ -34,7 +34,7 @@ const DESIGN_SPECS: Record<ViewportDesign, { width: number; height: number }> =
  */
 export const px2vw = (px: number, design: ViewportDesign = 'mvp'): string => {
   const baseWidth = DESIGN_SPECS[design].width;
-  return `calc(${px} * min(var(--vw, 1vw) * 100, 600PX) / ${baseWidth})`;
+  return `calc(${px} * min(var(--vw, 1vw) * 100, 500PX) / ${baseWidth})`;
 };
 
 /**

+ 5 - 5
sendEmailAsFeedBack/send_feedback.conf

@@ -1,5 +1,5 @@
-# 新用户注册邮件通知服务配置文件
-# 用于 send_new_user_email.py
+# 用户反馈邮件通知服务配置文件
+# 用于 send_feedback_email.py
 #
 # 说明:
 # 1. 数据库、SMTP 配置可参考 sendmsg/send.conf
@@ -12,7 +12,7 @@ smtp_host = smtpdm.aliyun.com
 smtp_port_ssl = 465
 smtp_username = support@mail.lyrria.com
 smtp_password = LRrria123456
-email_subject = Lyrria 新用户注册通知
+email_subject = Lyrria 用户反馈通知
 
 [database]
 # MySQL 数据库配置(与 sendmsg/send.conf 一致)
@@ -29,6 +29,6 @@ poll_interval = 600
 last_scan_end = 2026-02-13T09:20:22.054169
 
 [admin]
-# 管理员邮箱,多个用英文逗号分隔,收到新用户注册汇总邮件 keyu.liu.23@ucl.ac.uk,704541015@qq.com,tingw0525@gmail.com,liqun.he@ucl.ac.uk
-admin_emails = 704541015@qq.com
+# 管理员邮箱,多个用英文逗号分隔,收到用户反馈汇总邮件 keyu.liu.23@ucl.ac.uk,704541015@qq.com,tingw0525@gmail.com,liqun.he@ucl.ac.uk
+admin_emails = keyu.liu.23@ucl.ac.uk,704541015@qq.com,tingw0525@gmail.com,liqun.he@ucl.ac.uk
 

+ 18 - 3
sendEmailAsFeedBack/send_feedback_email.py

@@ -52,7 +52,7 @@ SMTP_HOST = config.get("smtp", "smtp_host", fallback="smtpdm.aliyun.com")
 SMTP_PORT_SSL = int(config.get("smtp", "smtp_port_ssl", fallback="465"))
 SMTP_USERNAME = config.get("smtp", "smtp_username", fallback="").strip()
 SMTP_PASSWORD = config.get("smtp", "smtp_password", fallback="").strip()
-EMAIL_SUBJECT = config.get("smtp", "email_subject", fallback="Lyrria 新用户反馈通知")
+DEFAULT_EMAIL_SUBJECT = "Lyrria 用户反馈通知"
 
 MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
 MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
@@ -74,12 +74,26 @@ def build_message(
     """构建纯文本邮件消息"""
     msg = MIMEMultipart("alternative")
     msg["Subject"] = Header(subject, "utf-8")
-    msg["From"] = formataddr(["Lyrria 管理员通知", sender])
+    msg["From"] = formataddr(["Lyrria 用户反馈通知", sender])
     msg["To"] = ",".join(recipients)
     msg.attach(MIMEText(body_text, "plain", "utf-8"))
     return msg
 
 
+def get_email_subject_from_config() -> str:
+    """
+    从配置读取邮件主题。
+    兼容历史误配置:若仍包含“新用户注册”,自动替换为“用户反馈”语义。
+    """
+    raw = config.get("smtp", "email_subject", fallback=DEFAULT_EMAIL_SUBJECT).strip()
+    if not raw:
+        return DEFAULT_EMAIL_SUBJECT
+    # 兼容旧模板/误配置
+    if "新用户注册" in raw:
+        return raw.replace("新用户注册", "用户反馈")
+    return raw
+
+
 def format_feedback_line(idx: int, fb: Feedback, user: Optional[User]) -> str:
     """将单条反馈+用户信息格式化为一行中文说明"""
     created = fb.created_at.strftime("%Y-%m-%d %H:%M:%S") if fb.created_at else "-"
@@ -249,10 +263,11 @@ async def run_once(db_manager: DatabaseManager) -> None:
 
     body = build_body(feedback_items, period_start, period_end)
     logger.info("📋 本周期新增用户反馈数: %d,正在发送通知邮件", len(feedback_items))
+    subject = get_email_subject_from_config()
     ok, _ = await asyncio.to_thread(
         send_email_sync,
         ADMIN_EMAILS,
-        EMAIL_SUBJECT,
+        subject,
         body,
         SMTP_USERNAME,
         SMTP_PASSWORD,