zlwu 7 місяців тому
батько
коміт
39e380c556

+ 195 - 113
backend/main.py

@@ -76,6 +76,7 @@ from services.energy_stone_service import energy_stone_service
 from services.scenario_topic_service import scenario_topic_service
 from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage, User
 from session_store import SessionStore
+from shop_item_image_utils import scene_image_name_from_shop_row
 
 # 配置日志
 logging.basicConfig(
@@ -1051,7 +1052,7 @@ async def get_chatbot_layout(user_id: str, _: None = Depends(require_user_sessio
                     SELECT
                         ub.z_index,
                         ub.backdrop_item_id,
-                        si.image_name
+                        COALESCE(NULLIF(TRIM(si.render_image_name), ''), si.image_name) AS image_name
                     FROM user_chatbot_backdrops ub
                     JOIN shop_items si
                       ON ub.backdrop_item_id = si.id
@@ -1208,126 +1209,134 @@ async def get_chatbot_layout(user_id: str, _: None = Depends(require_user_sessio
         raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
 
 
-@app.get("/api/users/{user_id}/chatbot-owned-items")
-async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_session)):
-    """获取用户已拥有的 ChatBot 商品(家具 + 幕布),用于前端背包展示。"""
-    try:
-        async with db_manager.get_session() as session:
-            async def _get_user_id_is_bigint(table_name: str) -> bool:
-                res = await session.execute(
-                    text(
-                        """
-                        SELECT DATA_TYPE
-                        FROM information_schema.columns
-                        WHERE table_schema = :db
-                          AND table_name = :table
-                          AND column_name = 'user_id'
-                        """
-                    ),
-                    {"db": settings.MYSQL_DATABASE, "table": table_name},
-                )
-                row = res.mappings().first()
-                data_type = (row or {}).get("DATA_TYPE") or ""
-                return "bigint" in str(data_type).lower()
+async def _mysql_user_id_column_is_bigint(session, table_name: str) -> bool:
+    res = await session.execute(
+        text(
+            """
+            SELECT DATA_TYPE
+            FROM information_schema.columns
+            WHERE table_schema = :db
+              AND table_name = :table
+              AND column_name = 'user_id'
+            """
+        ),
+        {"db": settings.MYSQL_DATABASE, "table": table_name},
+    )
+    row = res.mappings().first()
+    data_type = (row or {}).get("DATA_TYPE") or ""
+    return "bigint" in str(data_type).lower()
 
-            assets_user_id_expr = (
-                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
-                if await _get_user_id_is_bigint("user_assets")
-                else ":user_id"
-            )
-            layout_user_id_expr = (
-                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
-                if await _get_user_id_is_bigint("chatbot_layouts")
-                else ":user_id"
-            )
-            backdrop_user_id_expr = (
-                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
-                if await _get_user_id_is_bigint("user_chatbot_backdrops")
-                else ":user_id"
-            )
 
-            current_backdrop_res = await session.execute(
-                text(
-                    f"""
-                    SELECT ub.backdrop_item_id
-                    FROM user_chatbot_backdrops ub
-                    WHERE ub.user_id = {backdrop_user_id_expr}
-                    ORDER BY ub.updated_at DESC, ub.id DESC
-                    LIMIT 1
-                    """
-                ),
-                {"user_id": user_id},
-            )
-            current_backdrop_row = current_backdrop_res.mappings().first()
-            current_backdrop_item_id = (
-                int(current_backdrop_row["backdrop_item_id"])
-                if current_backdrop_row and current_backdrop_row.get("backdrop_item_id") is not None
-                else None
-            )
+async def _chatbot_in_use_item_ids(session, user_id: str) -> set[int]:
+    """当前幕布 + 槽位/布局中已装备的 shop_items.id 集合。"""
+    layout_user_id_expr = (
+        "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
+        if await _mysql_user_id_column_is_bigint(session, "chatbot_layouts")
+        else ":user_id"
+    )
+    backdrop_user_id_expr = (
+        "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
+        if await _mysql_user_id_column_is_bigint(session, "user_chatbot_backdrops")
+        else ":user_id"
+    )
 
-            in_use_item_ids: set[int] = set()
-            if current_backdrop_item_id is not None:
-                in_use_item_ids.add(current_backdrop_item_id)
+    current_backdrop_res = await session.execute(
+        text(
+            f"""
+            SELECT ub.backdrop_item_id
+            FROM user_chatbot_backdrops ub
+            WHERE ub.user_id = {backdrop_user_id_expr}
+            ORDER BY ub.updated_at DESC, ub.id DESC
+            LIMIT 1
+            """
+        ),
+        {"user_id": user_id},
+    )
+    current_backdrop_row = current_backdrop_res.mappings().first()
+    current_backdrop_item_id = (
+        int(current_backdrop_row["backdrop_item_id"])
+        if current_backdrop_row and current_backdrop_row.get("backdrop_item_id") is not None
+        else None
+    )
 
-            slot_table_res = await session.execute(
+    in_use_item_ids: set[int] = set()
+    if current_backdrop_item_id is not None:
+        in_use_item_ids.add(current_backdrop_item_id)
+
+    slot_table_res = await session.execute(
+        text(
+            """
+            SELECT COUNT(*) AS c
+            FROM information_schema.tables
+            WHERE table_schema = :db
+              AND table_name = 'user_chatbot_slot_placements'
+            """
+        ),
+        {"db": settings.MYSQL_DATABASE},
+    )
+    has_slot_table = int((slot_table_res.mappings().first() or {}).get("c") or 0) > 0
+    if has_slot_table and current_backdrop_item_id is not None:
+        slot_items_res = await session.execute(
+            text(
+                f"""
+                SELECT DISTINCT usp.item_id
+                FROM user_chatbot_slot_placements usp
+                WHERE usp.user_id = {layout_user_id_expr}
+                  AND usp.backdrop_item_id = :backdrop_item_id
+                  AND usp.item_id IS NOT NULL
+                """
+            ),
+            {"user_id": user_id, "backdrop_item_id": current_backdrop_item_id},
+        )
+        for row in slot_items_res.mappings().all():
+            iid = row.get("item_id")
+            if iid is not None:
+                in_use_item_ids.add(int(iid))
+    else:
+        latest_layout_res = await session.execute(
+            text(
+                f"""
+                SELECT l.id
+                FROM chatbot_layouts l
+                WHERE l.user_id = {layout_user_id_expr}
+                ORDER BY l.updated_at DESC, l.id DESC
+                LIMIT 1
+                """
+            ),
+            {"user_id": user_id},
+        )
+        latest_layout_row = latest_layout_res.mappings().first()
+        if latest_layout_row and latest_layout_row.get("id") is not None:
+            layout_item_res = await session.execute(
                 text(
                     """
-                    SELECT COUNT(*) AS c
-                    FROM information_schema.tables
-                    WHERE table_schema = :db
-                      AND table_name = 'user_chatbot_slot_placements'
+                    SELECT DISTINCT li.item_id
+                    FROM chatbot_layout_items li
+                    WHERE li.layout_id = :layout_id
+                      AND li.item_id IS NOT NULL
                     """
                 ),
-                {"db": settings.MYSQL_DATABASE},
+                {"layout_id": int(latest_layout_row["id"])},
             )
-            has_slot_table = int((slot_table_res.mappings().first() or {}).get("c") or 0) > 0
-            if has_slot_table and current_backdrop_item_id is not None:
-                slot_items_res = await session.execute(
-                    text(
-                        f"""
-                        SELECT DISTINCT usp.item_id
-                        FROM user_chatbot_slot_placements usp
-                        WHERE usp.user_id = {layout_user_id_expr}
-                          AND usp.backdrop_item_id = :backdrop_item_id
-                          AND usp.item_id IS NOT NULL
-                        """
-                    ),
-                    {"user_id": user_id, "backdrop_item_id": current_backdrop_item_id},
-                )
-                for row in slot_items_res.mappings().all():
-                    iid = row.get("item_id")
-                    if iid is not None:
-                        in_use_item_ids.add(int(iid))
-            else:
-                latest_layout_res = await session.execute(
-                    text(
-                        f"""
-                        SELECT l.id
-                        FROM chatbot_layouts l
-                        WHERE l.user_id = {layout_user_id_expr}
-                        ORDER BY l.updated_at DESC, l.id DESC
-                        LIMIT 1
-                        """
-                    ),
-                    {"user_id": user_id},
-                )
-                latest_layout_row = latest_layout_res.mappings().first()
-                if latest_layout_row and latest_layout_row.get("id") is not None:
-                    layout_item_res = await session.execute(
-                        text(
-                            """
-                            SELECT DISTINCT li.item_id
-                            FROM chatbot_layout_items li
-                            WHERE li.layout_id = :layout_id
-                              AND li.item_id IS NOT NULL
-                            """
-                        ),
-                        {"layout_id": int(latest_layout_row["id"])},
-                    )
-                    for row in layout_item_res.mappings().all():
-                        iid = row.get("item_id")
-                        if iid is not None:
-                            in_use_item_ids.add(int(iid))
+            for row in layout_item_res.mappings().all():
+                iid = row.get("item_id")
+                if iid is not None:
+                    in_use_item_ids.add(int(iid))
+
+    return in_use_item_ids
+
+
+@app.get("/api/users/{user_id}/chatbot-owned-items")
+async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_session)):
+    """获取用户已拥有的 ChatBot 商品(家具 + 幕布),用于前端背包展示。"""
+    try:
+        async with db_manager.get_session() as session:
+            assets_user_id_expr = (
+                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
+                if await _mysql_user_id_column_is_bigint(session, "user_assets")
+                else ":user_id"
+            )
+            in_use_item_ids = await _chatbot_in_use_item_ids(session, user_id)
 
             owned_res = await session.execute(
                 text(
@@ -1379,6 +1388,78 @@ async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_s
         raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
 
 
+@app.get("/api/users/{user_id}/chatbot-shop-catalog")
+async def get_chatbot_shop_catalog(user_id: str, _: None = Depends(require_user_session)):
+    """上架中的家具 + 幕布全库(含是否已拥有、是否当前使用中),用于 Lumi 商店等场景。"""
+    try:
+        async with db_manager.get_session() as session:
+            assets_user_id_expr = (
+                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
+                if await _mysql_user_id_column_is_bigint(session, "user_assets")
+                else ":user_id"
+            )
+            in_use_item_ids = await _chatbot_in_use_item_ids(session, user_id)
+
+            catalog_res = await session.execute(
+                text(
+                    f"""
+                    SELECT
+                        si.id,
+                        si.item_code,
+                        si.name,
+                        si.name_zh,
+                        si.name_en,
+                        si.has_special_scene_trigger,
+                        si.image_name,
+                        si.item_type,
+                        si.biz_type,
+                        si.sort_order,
+                        si.price_energy,
+                        CASE
+                            WHEN EXISTS (
+                                SELECT 1
+                                FROM user_assets ua
+                                WHERE ua.item_id = si.id
+                                  AND ua.user_id = {assets_user_id_expr}
+                                  AND COALESCE(ua.owned_qty, 0) > 0
+                            )
+                            THEN 1
+                            ELSE 0
+                        END AS is_owned
+                    FROM shop_items si
+                    WHERE si.is_active = 1
+                      AND si.item_type IN ('furniture', 'backdrop')
+                    ORDER BY si.sort_order ASC, si.id ASC
+                    """
+                ),
+                {"user_id": user_id},
+            )
+            rows = catalog_res.mappings().all()
+            return {
+                "success": True,
+                "data": [
+                    {
+                        "item_id": int(r["id"]),
+                        "item_code": r.get("item_code"),
+                        "name": r.get("name"),
+                        "name_zh": r.get("name_zh"),
+                        "name_en": r.get("name_en"),
+                        "has_special_scene_trigger": int(r.get("has_special_scene_trigger") or 0),
+                        "image_name": r.get("image_name"),
+                        "item_type": r.get("item_type"),
+                        "biz_type": r.get("biz_type"),
+                        "is_in_use": int(r["id"]) in in_use_item_ids,
+                        "is_owned": bool(int(r.get("is_owned") or 0)),
+                        "price_energy": float(r.get("price_energy") or 0),
+                    }
+                    for r in rows
+                ],
+            }
+    except Exception as exc:  # noqa: BLE001
+        logger.exception("获取 ChatBot 商品库失败: %s", exc)
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 @app.put("/api/users/{user_id}/chatbot-backdrop")
 async def update_chatbot_backdrop(
     user_id: str,
@@ -1526,6 +1607,7 @@ async def equip_chatbot_furniture(
                         si.id AS item_id,
                         si.item_type,
                         si.image_name,
+                        si.render_image_name,
                         COALESCE(si.default_z_index, 0) AS default_z_index
                     FROM user_assets ua
                     JOIN shop_items si ON si.id = ua.item_id
@@ -1732,7 +1814,7 @@ async def equip_chatbot_furniture(
                     "user_asset_id": int(owned_row.get("user_asset_id") or 0),
                     "item_id": int(request.item_id),
                     "z_index": int(owned_row.get("default_z_index") or 0),
-                    "image_name_snapshot": str(owned_row.get("image_name") or ""),
+                    "image_name_snapshot": scene_image_name_from_shop_row(owned_row),
                 },
             )
 

+ 7 - 0
backend/migrations/add_shop_items_render_image_name.sql

@@ -0,0 +1,7 @@
+-- shop_items:场景网格渲染用图片(OSS 文件名);为空则与 image_name 相同
+-- 执行前请备份;可重复执行(需手动判断列是否已存在时可拆成两步)
+
+ALTER TABLE `shop_items`
+  ADD COLUMN `render_image_name` VARCHAR(255) NULL DEFAULT NULL
+    COMMENT '场景/网格渲染用图片文件名(OSS);为空则使用 image_name'
+  AFTER `image_name`;

+ 8 - 0
backend/migrations/add_users_stone_balance_columns.sql

@@ -0,0 +1,8 @@
+-- 用户表:对话能量石 / 水晶石展示余额(与明细表同事务维护,供接口与界面直读)
+-- 执行前请备份。MySQL 5.7+ / 8.0+
+
+ALTER TABLE `users`
+  ADD COLUMN `conversation_energy_stone_total` INT NOT NULL DEFAULT 0
+    COMMENT '对话能量石余额(对话获得+赠送-购物消耗,不低于0)' AFTER `user_level`,
+  ADD COLUMN `crystal_stone_total` INT NOT NULL DEFAULT 0
+    COMMENT '水晶石余额(任务等获得-购物消耗,不低于0)' AFTER `conversation_energy_stone_total`;

+ 11 - 0
backend/migrations/backfill_user_chatbot_slot_placements_scene_snapshots.sql

@@ -0,0 +1,11 @@
+-- 根据 shop_items.render_image_name 回填槽位快照(已有装备过家具的用户)
+-- 前提:已执行 add_shop_items_render_image_name.sql
+-- 仅当商品配置了 render_image_name 且与当前 snapshot 不一致时更新为场景图
+
+UPDATE `user_chatbot_slot_placements` usp
+INNER JOIN `shop_items` si ON si.id = usp.item_id
+SET usp.`image_name_snapshot` = NULLIF(TRIM(si.`render_image_name`), ''),
+    usp.`updated_at` = NOW()
+WHERE usp.`item_id` IS NOT NULL
+  AND NULLIF(TRIM(si.`render_image_name`), '') IS NOT NULL
+  AND usp.`image_name_snapshot` <> NULLIF(TRIM(si.`render_image_name`), '');

+ 56 - 0
backend/migrations/backfill_users_stone_balances.sql

@@ -0,0 +1,56 @@
+-- 将历史数据回填到 users.conversation_energy_stone_total / crystal_stone_total
+-- 规则与 EnergyStoneService.get_stone_counts 对齐(对话侧):
+--   对话余额 = COUNT(conversation_energy_stones) + bonus - FLOOR(SUM(asset_purchase_logs.cost_energy))
+-- 水晶余额 = COUNT(mission_energy_stones) - FLOOR(SUM(asset_purchase_logs.cost_crystal))
+-- 若库中无 asset_purchase_logs 表,请先注释掉 spent 相关子查询,或仅执行第一节 ALTER 后用手工脚本。
+--
+-- 说明:若购物消耗仅记在 purchase_orders(无流水表),需单独按订单汇总后再 UPDATE,本脚本未合并订单表逻辑。
+
+-- 1) 对话能量石余额
+UPDATE `users` u
+INNER JOIN (
+  SELECT
+    uu.user_id AS uid,
+    GREATEST(
+      0,
+      COALESCE(ce.cnt, 0)
+        + COALESCE(b.bonus_conversation_count, 0)
+        - COALESCE(sp.energy_spent, 0)
+    ) AS bal
+  FROM `users` uu
+  LEFT JOIN (
+    SELECT user_id, COUNT(*) AS cnt
+    FROM `conversation_energy_stones`
+    GROUP BY user_id
+  ) ce ON ce.user_id = uu.user_id
+  LEFT JOIN `user_bonus_energy_stones` b ON b.user_id = uu.user_id
+  LEFT JOIN (
+    SELECT user_id, FLOOR(COALESCE(SUM(cost_energy), 0)) AS energy_spent
+    FROM `asset_purchase_logs`
+    GROUP BY user_id
+  ) sp ON sp.user_id = uu.id
+) x ON x.uid = u.user_id
+SET u.conversation_energy_stone_total = x.bal;
+
+-- 2) 水晶石余额
+UPDATE `users` u
+INNER JOIN (
+  SELECT
+    uu.user_id AS uid,
+    GREATEST(
+      0,
+      COALESCE(ms.cnt, 0) - COALESCE(sp.crystal_spent, 0)
+    ) AS bal
+  FROM `users` uu
+  LEFT JOIN (
+    SELECT user_id, COUNT(*) AS cnt
+    FROM `mission_energy_stones`
+    GROUP BY user_id
+  ) ms ON ms.user_id = uu.user_id
+  LEFT JOIN (
+    SELECT user_id, FLOOR(COALESCE(SUM(cost_crystal), 0)) AS crystal_spent
+    FROM `asset_purchase_logs`
+    GROUP BY user_id
+  ) sp ON sp.user_id = uu.id
+) x ON x.uid = u.user_id
+SET u.crystal_stone_total = x.bal;

+ 69 - 12
backend/migrations/migrate_backfill_default_chatbot_scene.py

@@ -25,6 +25,8 @@ if str(backend_dir) not in sys.path:
 
 from config import settings
 
+from shop_item_image_utils import scene_image_name_from_shop_row
+
 
 REQUIRED_TABLES = [
     "users",
@@ -54,6 +56,19 @@ async def _table_exists(conn: aiomysql.Connection, table_name: str) -> bool:
         return bool(row and row[0] > 0)
 
 
+async def _column_exists(conn: aiomysql.Connection, table: str, col: str) -> bool:
+    async with conn.cursor() as cursor:
+        await cursor.execute(
+            """
+            SELECT COUNT(*) FROM information_schema.columns
+            WHERE table_schema=%s AND table_name=%s AND column_name=%s
+            """,
+            (settings.MYSQL_DATABASE, table, col),
+        )
+        row: Any = await cursor.fetchone()
+        return bool(row and row[0] > 0)
+
+
 async def _col_is_bigint(conn: aiomysql.Connection, table: str, col: str) -> bool:
     async with conn.cursor() as cursor:
         await cursor.execute(
@@ -93,17 +108,47 @@ async def migrate() -> bool:
         ub_uid_expr = "u.id" if ub_bigint else "u.user_id"
         up_uid_expr = "u.id" if up_bigint else "u.user_id"
 
+        has_render_col = await _column_exists(conn, "shop_items", "render_image_name")
+
         async with conn.cursor() as cursor:
-            await cursor.execute(
-                """
-                SELECT item_code, id, image_name, default_z_index
-                FROM shop_items
-                WHERE item_code IN (%s,%s,%s)
-                """,
-                (ITEM_CODES["backdrop"], ITEM_CODES["seat"], ITEM_CODES["window"]),
-            )
-            rows = await cursor.fetchall()
-            by_code = {r[0]: {"id": int(r[1]), "image_name": str(r[2]), "z": int(r[3] or 0)} for r in rows}
+            if has_render_col:
+                await cursor.execute(
+                    """
+                    SELECT item_code, id, image_name, render_image_name, default_z_index
+                    FROM shop_items
+                    WHERE item_code IN (%s,%s,%s)
+                    """,
+                    (ITEM_CODES["backdrop"], ITEM_CODES["seat"], ITEM_CODES["window"]),
+                )
+                rows = await cursor.fetchall()
+                by_code = {
+                    r[0]: {
+                        "id": int(r[1]),
+                        "image_name": str(r[2]),
+                        "render_image_name": r[3],
+                        "z": int(r[4] or 0),
+                    }
+                    for r in rows
+                }
+            else:
+                await cursor.execute(
+                    """
+                    SELECT item_code, id, image_name, default_z_index
+                    FROM shop_items
+                    WHERE item_code IN (%s,%s,%s)
+                    """,
+                    (ITEM_CODES["backdrop"], ITEM_CODES["seat"], ITEM_CODES["window"]),
+                )
+                rows = await cursor.fetchall()
+                by_code = {
+                    r[0]: {
+                        "id": int(r[1]),
+                        "image_name": str(r[2]),
+                        "render_image_name": None,
+                        "z": int(r[3] or 0),
+                    }
+                    for r in rows
+                }
             for k, code in ITEM_CODES.items():
                 if code not in by_code:
                     print(f"❌ shop_items 缺少 {code}")
@@ -174,7 +219,13 @@ async def migrate() -> bool:
                   image_name_snapshot=VALUES(image_name_snapshot),
                   updated_at=NOW()
                 """,
-                (backdrop["id"], seat["id"], seat["z"], seat["image_name"], seat["id"]),
+                (
+                    backdrop["id"],
+                    seat["id"],
+                    seat["z"],
+                    scene_image_name_from_shop_row(seat),
+                    seat["id"],
+                ),
             )
 
             await cursor.execute(
@@ -202,7 +253,13 @@ async def migrate() -> bool:
                   image_name_snapshot=VALUES(image_name_snapshot),
                   updated_at=NOW()
                 """,
-                (backdrop["id"], window["id"], window["z"], window["image_name"], window["id"]),
+                (
+                    backdrop["id"],
+                    window["id"],
+                    window["z"],
+                    scene_image_name_from_shop_row(window),
+                    window["id"],
+                ),
             )
             print("✅ user_chatbot_slot_placements 默认槽位补齐完成")
 

+ 14 - 0
backend/models_db.py

@@ -59,6 +59,18 @@ class User(Base):
     total_rounds = Column(Integer, default=0, comment="总对话轮次")
     total_speech_duration_ms = Column(BigInteger, default=0, comment="累积说话时长(毫秒)")
     user_level = Column(Integer, default=0, nullable=False, comment="用户等级(0-5)")
+    conversation_energy_stone_total = Column(
+        Integer,
+        default=0,
+        nullable=False,
+        comment="对话能量石余额(对话获得+赠送-购物消耗,展示与扣减以此为准)",
+    )
+    crystal_stone_total = Column(
+        Integer,
+        default=0,
+        nullable=False,
+        comment="水晶石余额(任务等获得-购物消耗,展示与扣减以此为准)",
+    )
     preferred_language = Column(String(8), nullable=True, comment="首选语言(zh/en)")
     last_active_at = Column(TIMESTAMP, nullable=True, comment="最后活跃时间")
     
@@ -92,6 +104,8 @@ class User(Base):
             "total_rounds": self.total_rounds,
             "total_speech_duration_ms": self.total_speech_duration_ms,
             "user_level": self.user_level,
+            "conversation_energy_stone_total": self.conversation_energy_stone_total or 0,
+            "crystal_stone_total": self.crystal_stone_total or 0,
             "preferred_language": self.preferred_language,
             "last_active_at": self.last_active_at.isoformat() if self.last_active_at else None,
             "current_background_image_id": self.current_background_image_id,

+ 78 - 30
backend/services/energy_stone_service.py

@@ -8,11 +8,16 @@ import time
 from datetime import datetime, timezone
 from typing import Any, Dict, List, Literal, Optional, Tuple
 
-from sqlalchemy import select, func, text
+from sqlalchemy import select, func, text, update
 from sqlalchemy.exc import IntegrityError
 
 from database import db_manager
-from models_db import MissionEnergyStone, ConversationEnergyStone, UserBonusEnergyStone, MissionType
+from models_db import (
+    MissionEnergyStone,
+    ConversationEnergyStone,
+    MissionType,
+    User,
+)
 from config import settings
 
 logger = logging.getLogger(__name__)
@@ -118,6 +123,11 @@ class EnergyStoneService:
                         metadata=metadata or {},
                     )
                     session.add(stone)
+                    await session.execute(
+                        update(User)
+                        .where(User.user_id == user_id)
+                        .values(crystal_stone_total=User.crystal_stone_total + 1)
+                    )
 
                 await session.refresh(stone)
 
@@ -190,6 +200,15 @@ class EnergyStoneService:
                 if not created_instances:
                     return []
 
+                n_new = len(created_instances)
+                await session.execute(
+                    update(User)
+                    .where(User.user_id == user_id)
+                    .values(
+                        conversation_energy_stone_total=User.conversation_energy_stone_total
+                        + n_new,
+                    )
+                )
                 await session.commit()
                 for instance in created_instances:
                     await session.refresh(instance)
@@ -332,47 +351,76 @@ class EnergyStoneService:
             return 0
 
     async def get_stone_counts(self, user_id: str) -> Dict[str, int]:
-        """获取用户的能量石数量(对话能量石 = 获得 + 赠送 - 购物已消耗,不低于 0)。"""
+        """从 users 表读取展示余额(与发放/赠送/购物扣减同事务维护)。"""
         if not user_id:
             return {"mission_count": 0, "conversation_count": 0}
 
         try:
             async with self.db.get_session() as session:
-                # 获取任务能量石数量
-                mission_result = await session.execute(
-                    select(func.count(MissionEnergyStone.stone_id)).where(
-                        MissionEnergyStone.user_id == user_id
-                    )
-                )
-                mission_count = mission_result.scalar() or 0
-
-                # 对话能量石数量 = 对话获得的条数 + 赠送数量
-                conversation_result = await session.execute(
-                    select(func.count(ConversationEnergyStone.stone_id)).where(
-                        ConversationEnergyStone.user_id == user_id
-                    )
-                )
-                conversation_earned = conversation_result.scalar() or 0
-
-                bonus_result = await session.execute(
-                    select(UserBonusEnergyStone.bonus_conversation_count).where(
-                        UserBonusEnergyStone.user_id == user_id
+                row = await session.execute(
+                    select(User.crystal_stone_total, User.conversation_energy_stone_total).where(
+                        User.user_id == user_id
                     )
                 )
-                bonus_count = bonus_result.scalar() or 0
-
-                spent_shopping = await self._sum_conversation_energy_spent_shopping(session, user_id)
-                raw_total = int(conversation_earned) + int(bonus_count) - int(spent_shopping)
-                conversation_count = max(0, raw_total)
-
+                tup = row.one_or_none()
+                if not tup:
+                    return {"mission_count": 0, "conversation_count": 0}
+                crystal, conv = tup[0], tup[1]
                 return {
-                    "mission_count": mission_count,
-                    "conversation_count": conversation_count,
+                    "mission_count": max(0, int(crystal or 0)),
+                    "conversation_count": max(0, int(conv or 0)),
                 }
         except Exception as exc:
             logger.warning("获取能量石数量失败: user=%s error=%s", user_id, exc)
             return {"mission_count": 0, "conversation_count": 0}
 
+    async def apply_shopping_deduction(
+        self,
+        user_id: str,
+        energy_blocks: int = 0,
+        crystal_blocks: int = 0,
+    ) -> None:
+        """
+        购物流水写入成功后调用:扣减 users 表上的对话能量石/水晶石余额(不低于 0)。
+        energy_blocks / crystal_blocks 为整颗数(与 cost_energy / cost_crystal 向下取整后一致)。
+        """
+        if not user_id:
+            return
+        e = max(0, int(energy_blocks))
+        c = max(0, int(crystal_blocks))
+        if e == 0 and c == 0:
+            return
+        try:
+            async with self.db.get_session() as session:
+                async with session.begin():
+                    if e > 0:
+                        await session.execute(
+                            update(User)
+                            .where(User.user_id == user_id)
+                            .values(
+                                conversation_energy_stone_total=func.greatest(
+                                    0, User.conversation_energy_stone_total - e
+                                ),
+                            )
+                        )
+                    if c > 0:
+                        await session.execute(
+                            update(User)
+                            .where(User.user_id == user_id)
+                            .values(
+                                crystal_stone_total=func.greatest(0, User.crystal_stone_total - c),
+                            )
+                        )
+        except Exception as exc:
+            logger.warning(
+                "购物扣减用户能量石余额失败: user=%s energy=%s crystal=%s error=%s",
+                user_id,
+                e,
+                c,
+                exc,
+            )
+            raise
+
     async def get_active_stones(self, user_id: str) -> List[Dict[str, Any]]:
         """获取用户未过期的能量石(用于页面恢复)"""
         if not user_id:

+ 6 - 5
backend/services/user_service.py

@@ -35,6 +35,7 @@ from error_codes import (
     CODE_REQUIRED,
     CODE_INVALID,
 )
+from shop_item_image_utils import scene_image_name_from_shop_row
 
 logger = logging.getLogger(__name__)
 
@@ -254,7 +255,7 @@ class UserService:
             shop_res = await session.execute(
                 text(
                     f"""
-                    SELECT id, item_code, image_name, default_z_index,
+                    SELECT id, item_code, image_name, render_image_name, default_z_index,
                            size_rows, size_cols, placement_type
                     FROM shop_items
                     WHERE item_code IN ({codes_in}) AND is_active = 1
@@ -422,7 +423,7 @@ class UserService:
                         "uaid": int(stump_ua_id),
                         "iid": stump_item_id,
                         "z": int(stump_row["default_z_index"] or 0),
-                        "img": str(stump_row["image_name"]),
+                        "img": scene_image_name_from_shop_row(stump_row),
                     },
                 )
                 await session.execute(
@@ -446,7 +447,7 @@ class UserService:
                         "uaid": int(window_ua_id),
                         "iid": window_item_id,
                         "z": int(window_row["default_z_index"] or 0),
-                        "img": str(window_row["image_name"]),
+                        "img": scene_image_name_from_shop_row(window_row),
                     },
                 )
             elif await self._mysql_table_exists(session, "chatbot_layout_items"):
@@ -477,7 +478,7 @@ class UserService:
                             "uaid": int(stump_ua_id),
                             "iid": stump_item_id,
                             "z": int(stump_row["default_z_index"] or 0),
-                            "img": str(stump_row["image_name"]),
+                            "img": scene_image_name_from_shop_row(stump_row),
                         },
                     )
 
@@ -507,7 +508,7 @@ class UserService:
                             "uaid": int(window_ua_id),
                             "iid": window_item_id,
                             "z": int(window_row["default_z_index"] or 0),
-                            "img": str(window_row["image_name"]),
+                            "img": scene_image_name_from_shop_row(window_row),
                         },
                     )
 

+ 14 - 0
backend/shop_item_image_utils.py

@@ -0,0 +1,14 @@
+"""商品图片:列表缩略图 image_name vs 场景网格 render_image_name。"""
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+
+def scene_image_name_from_shop_row(row: Mapping[str, Any] | None) -> str:
+    """场景/槽位快照使用的 OSS 文件名:优先 render_image_name,否则 image_name。"""
+    if not row:
+        return ""
+    alt = (row.get("render_image_name") or "").strip()
+    if alt:
+        return alt
+    return str(row.get("image_name") or "")

+ 7 - 0
backend/tests/test_energy_stones.py

@@ -98,3 +98,10 @@ async def test_conversation_stone_created_at_30s_threshold(db_manager, seeded_us
         assert stored[0].threshold_index == 1
         assert stored[0].total_duration_ms == 30_000
 
+        urow = await session.execute(select(User).where(User.user_id == seeded_user))
+        u = urow.scalar_one()
+        assert (u.conversation_energy_stone_total or 0) == 1
+
+    counts = await service.get_stone_counts(seeded_user)
+    assert counts["conversation_count"] == 1
+

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
frontend/src/i18n/locales/en/profile.json


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
frontend/src/i18n/locales/zh/profile.json


+ 206 - 178
frontend/src/pages/ChatBotPage.tsx

@@ -1623,6 +1623,10 @@ export const ChatBotPage = () => {
           if (typeof userInfo.user_level === 'number') {
             syncUserLevel(userInfo.user_level);
           }
+
+          if (typeof userInfo.conversation_energy_stone_total === 'number') {
+            setConversationStoneCount(userInfo.conversation_energy_stone_total);
+          }
           
           // Lyrria_is_logged_in (根据 user_type 判断:registered 为已登录)
           if (userInfo.user_type === 'registered') {
@@ -3007,14 +3011,11 @@ export const ChatBotPage = () => {
   const cellHeight = chatbotGrid.cell_height ?? 96;
   const gridRows = chatbotGrid.grid_rows ?? 5;
   const gridCols = chatbotGrid.grid_cols ?? 4;
-  const decorAreaWidth = chatbotGrid.decor_area_width ?? 393;
-
   // 根据你给的默认约定:小木桩在 row=4 col=2 的单元格起点。
   // `STOOL_LAYOUT.top` 是旧实现里 row=4 的锚点,因此可反推网格 row=1 的 top。
-  // 现在要求:5x4 区域距离整个界面顶部为 0
+  // 5×4 区域距界面顶部为 0;水平方向在幕布舞台内居中(见下方 stage 内列容器)
   const gridOriginY = 0;
-  // 网格在 393 宽内居中:总宽 = gridCols * cellWidth
-  const gridOriginX = (decorAreaWidth - gridCols * cellWidth) / 2;
+  const gridOriginX = 0;
 
   const defaultGifUrl = avatarGifSet?.default ?? '';
   // 双 img 固定角色:default 层永远 default;对话层 playing 时播 emotion/talking
@@ -3033,210 +3034,233 @@ export const ChatBotPage = () => {
         minHeight: '100dvh',
       }}
     >
-      {/* 背景幕布 */}
-      {activeBackdropImageName && (
-        <div
-          className="absolute pointer-events-none"
-          style={{
-            width: '100%',
-            height: '100%',
-            left: 0,
-            top: 0,
-            zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
-          }}
-        >
-          <img
-            src={getOssUrl(activeBackdropImageName)}
-            alt=""
-            className="absolute"
-            fetchPriority="low"
-            loading="lazy"
-            draggable={false}
+      {/* 幕布舞台:背景与 5×4 家具/特殊场景共用;网格列在层内水平居中 */}
+      <div
+        className="absolute pointer-events-none"
+        style={{
+          left: 0,
+          top: 0,
+          width: '100%',
+          height: '100%',
+          zIndex: 0,
+        }}
+      >
+        {activeBackdropImageName ? (
+          <div
+            className="absolute pointer-events-none"
             style={{
               width: '100%',
               height: '100%',
               left: 0,
               top: 0,
-              objectFit: 'cover', // 铺满整个界面
+              zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
             }}
-          />
-        </div>
-      )}
-
-      {/* 顶部 5x4 单元格边框线(调试/展示用) */}
-      <div
-        className="absolute pointer-events-none"
-        style={{
-          left: px2vmin(gridOriginX),
-          top: px2vmin(gridOriginY),
-          width: px2vmin(gridCols * cellWidth),
-          height: px2vmin(gridRows * cellHeight),
-          zIndex: CHATBOT_Z_INDEX.GRID_LINES,
-          border: '1px solid rgba(255, 255, 255, 0.35)',
-          boxSizing: 'border-box',
-        }}
-      >
-        {Array.from({ length: gridCols - 1 }, (_, idx) => {
-          const col = idx + 1;
-          return (
-            <div
-              key={col}
+          >
+            <img
+              src={getOssUrl(activeBackdropImageName)}
+              alt=""
+              className="absolute"
+              fetchPriority="low"
+              loading="lazy"
+              draggable={false}
               style={{
-                position: 'absolute',
-                top: 0,
-                left: px2vmin(col * cellWidth),
-                width: 1,
+                width: '100%',
                 height: '100%',
-                backgroundColor: 'rgba(255, 255, 255, 0.35)',
-              }}
-            />
-          );
-        })}
-
-        {Array.from({ length: gridRows - 1 }, (_, idx) => {
-          const row = idx + 1;
-          return (
-            <div
-              key={row}
-              style={{
-                position: 'absolute',
                 left: 0,
-                top: px2vmin(row * cellHeight),
-                height: 1,
-                width: '100%',
-                backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                top: 0,
+                objectFit: 'cover', // 铺满整个界面
               }}
             />
-          );
-        })}
-      </div>
+          </div>
+        ) : null}
 
-      {/* 家具网格:按后端的 start_row/start_col/size_rows/size_cols 渲染 */}
-      {activeLayoutItems.map((item, idx) => {
-        const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
-        const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
-        const slotWidthPx = item.size_cols * cellWidth;
-        const slotHeightPx = item.size_rows * cellHeight;
-        const isPixelSlot = item.render_mode === 'pixel';
-        const offsetX = item.offset_x ?? 0;
-        const offsetY = item.offset_y ?? 0;
-        const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
-        const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
-        const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
-        const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
-        return (
-          <img
-            key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
-            src={getOssUrl(item.image_name_snapshot)}
-            alt=""
+        <div
+          className="absolute pointer-events-none"
+          style={{
+            left: '50%',
+            transform: 'translateX(-50%)',
+            width: px2vmin(gridCols * cellWidth),
+            top: 0,
+            bottom: 0,
+            zIndex: 1,
+          }}
+        >
+          {/* 顶部 5x4 单元格边框线(调试/展示用) */}
+          <div
             className="absolute pointer-events-none"
-            fetchPriority="low"
-            loading="lazy"
-            draggable={false}
             style={{
-              width: px2vmin(widthPx),
-              height: px2vmin(heightPx),
-              left: px2vmin(leftPx),
-              top: px2vmin(topPx),
-              // 录音态会出现 zIndex=2 的阴影蒙层;将家具放到其上方,避免“先出现后消失”
-              zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
-              objectFit: 'contain',
+              left: px2vmin(gridOriginX),
+              top: px2vmin(gridOriginY),
+              width: px2vmin(gridCols * cellWidth),
+              height: px2vmin(gridRows * cellHeight),
+              zIndex: CHATBOT_Z_INDEX.GRID_LINES,
+              border: '1px solid rgba(255, 255, 255, 0.35)',
+              boxSizing: 'border-box',
             }}
-          />
-        );
-      })}
-
-      {/* 特殊场景:窗前=窗户区域左下角;看书/吃饭=凳子区域水平居中、底边对齐(像坐在凳子上) */}
-      {greetingContext?.scene &&
-        greetingContext.scene !== 'default' &&
-        !specialSceneAvatarDismissed &&
-        SPECIAL_SCENE_GIF[greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF] && (
-          (() => {
-            const scene = greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF;
-            const gifSize = STOOL_LAYOUT.sceneGifSize;
-
-            // 场景锚点:由后端按 scene_anchor_type 过滤后随机选中,前端只按锚点 ID 渲染
-            const anchorId = greetingContext.anchorLayoutItemId;
-            const anchorItem =
-              anchorId != null
-                ? activeLayoutItems.find((i) => Number(i.id) === Number(anchorId))
-                : null;
-
-            if (!anchorItem) {
-              // 锚点不存在时不渲染特殊场景形象(后端通常会回退 default)
-              return null;
-            }
-
-            const anchorLeft = gridOriginX + (anchorItem.start_col - 1) * cellWidth;
-            const anchorTop = gridOriginY + (anchorItem.start_row - 1) * cellHeight;
-            const anchorWidth = anchorItem.size_cols * cellWidth;
-            const anchorHeight = anchorItem.size_rows * cellHeight;
-
-            const isWindow = scene === 'window';
-            const offsetY = isWindow
-              ? (STOOL_LAYOUT.sceneGifOffsetYByScene[scene] ?? 0)
-              : STOOL_LAYOUT.sceneGifOffsetY;
-
-            const avatarZ =
-              greetingContext.specialAvatarZIndex ?? (Math.max(anchorItem.z_index ?? 0, 2) + 1);
-
-            // 窗前:绑定物=窗户矩形;形象相对该矩形用 left/bottom 百分比,形成「站在窗前」的透视(非相对整屏绝对坐标)
-            if (isWindow) {
+          >
+            {Array.from({ length: gridCols - 1 }, (_, idx) => {
+              const col = idx + 1;
               return (
                 <div
-                  className="absolute pointer-events-none"
+                  key={col}
                   style={{
-                    left: px2vmin(anchorLeft),
-                    top: px2vmin(anchorTop),
-                    width: px2vmin(anchorWidth),
-                    height: px2vmin(anchorHeight),
-                    zIndex: avatarZ,
+                    position: 'absolute',
+                    top: 0,
+                    left: px2vmin(col * cellWidth),
+                    width: 1,
+                    height: '100%',
+                    backgroundColor: 'rgba(255, 255, 255, 0.35)',
                   }}
-                >
-                  <img
-                    src={SPECIAL_SCENE_GIF[scene]}
-                    alt=""
-                    loading="lazy"
-                    draggable={false}
-                    className="pointer-events-none"
-                    style={{
-                      position: 'absolute',
-                      left: '-15%',
-                      bottom: '-50%',
-                      width: px2vmin(gifSize),
-                      height: px2vmin(gifSize),
-                      objectFit: 'contain',
-                      objectPosition: 'left bottom',
-                    }}
-                  />
-                </div>
+                />
               );
-            }
+            })}
 
-            // 看书/吃饭:相对凳子锚点水平居中、底边对齐凳面附近
-            const gifLeft = anchorLeft + anchorWidth / 2 - gifSize / 2;
-            const gifTop = anchorTop + anchorHeight - gifSize + offsetY;
+            {Array.from({ length: gridRows - 1 }, (_, idx) => {
+              const row = idx + 1;
+              return (
+                <div
+                  key={row}
+                  style={{
+                    position: 'absolute',
+                    left: 0,
+                    top: px2vmin(row * cellHeight),
+                    height: 1,
+                    width: '100%',
+                    backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                  }}
+                />
+              );
+            })}
+          </div>
 
+          {/* 家具网格:按后端的 start_row/start_col/size_rows/size_cols 渲染 */}
+          {activeLayoutItems.map((item, idx) => {
+            const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
+            const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
+            const slotWidthPx = item.size_cols * cellWidth;
+            const slotHeightPx = item.size_rows * cellHeight;
+            const isPixelSlot = item.render_mode === 'pixel';
+            const offsetX = item.offset_x ?? 0;
+            const offsetY = item.offset_y ?? 0;
+            const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
+            const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
+            const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+            const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
             return (
               <img
-                src={SPECIAL_SCENE_GIF[scene]}
+                key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
+                src={getOssUrl(item.image_name_snapshot)}
                 alt=""
                 className="absolute pointer-events-none"
+                fetchPriority="low"
                 loading="lazy"
+                draggable={false}
                 style={{
-                  width: px2vmin(gifSize),
-                  height: px2vmin(gifSize),
-                  left: px2vmin(gifLeft),
-                  top: px2vmin(gifTop),
-                  zIndex: avatarZ,
+                  width: px2vmin(widthPx),
+                  height: px2vmin(heightPx),
+                  left: px2vmin(leftPx),
+                  top: px2vmin(topPx),
+                  // 录音态会出现 zIndex=2 的阴影蒙层;将家具放到其上方,避免“先出现后消失”
+                  zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
                   objectFit: 'contain',
-                  objectPosition: 'center bottom',
                 }}
-                draggable={false}
               />
             );
-          })()
-        )}
+          })}
+
+          {/* 特殊场景:窗前=窗户区域左下角;看书/吃饭=凳子区域水平居中、底边对齐(像坐在凳子上) */}
+          {greetingContext?.scene &&
+            greetingContext.scene !== 'default' &&
+            !specialSceneAvatarDismissed &&
+            SPECIAL_SCENE_GIF[greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF] && (
+              (() => {
+                const scene = greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF;
+                const gifSize = STOOL_LAYOUT.sceneGifSize;
+
+                // 场景锚点:由后端按 scene_anchor_type 过滤后随机选中,前端只按锚点 ID 渲染
+                const anchorId = greetingContext.anchorLayoutItemId;
+                const anchorItem =
+                  anchorId != null
+                    ? activeLayoutItems.find((i) => Number(i.id) === Number(anchorId))
+                    : null;
+
+                if (!anchorItem) {
+                  // 锚点不存在时不渲染特殊场景形象(后端通常会回退 default)
+                  return null;
+                }
+
+                const anchorLeft = gridOriginX + (anchorItem.start_col - 1) * cellWidth;
+                const anchorTop = gridOriginY + (anchorItem.start_row - 1) * cellHeight;
+                const anchorWidth = anchorItem.size_cols * cellWidth;
+                const anchorHeight = anchorItem.size_rows * cellHeight;
+
+                const isWindow = scene === 'window';
+                const offsetY = isWindow
+                  ? (STOOL_LAYOUT.sceneGifOffsetYByScene[scene] ?? 0)
+                  : STOOL_LAYOUT.sceneGifOffsetY;
+
+                const avatarZ =
+                  greetingContext.specialAvatarZIndex ?? (Math.max(anchorItem.z_index ?? 0, 2) + 1);
+
+                // 窗前:绑定物=窗户矩形;形象相对该矩形用 left/bottom 百分比,形成「站在窗前」的透视(非相对整屏绝对坐标)
+                if (isWindow) {
+                  return (
+                    <div
+                      className="absolute pointer-events-none"
+                      style={{
+                        left: px2vmin(anchorLeft),
+                        top: px2vmin(anchorTop),
+                        width: px2vmin(anchorWidth),
+                        height: px2vmin(anchorHeight),
+                        zIndex: avatarZ,
+                      }}
+                    >
+                      <img
+                        src={SPECIAL_SCENE_GIF[scene]}
+                        alt=""
+                        loading="lazy"
+                        draggable={false}
+                        className="pointer-events-none"
+                        style={{
+                          position: 'absolute',
+                          left: '-15%',
+                          bottom: '-50%',
+                          width: px2vmin(gifSize),
+                          height: px2vmin(gifSize),
+                          objectFit: 'contain',
+                          objectPosition: 'left bottom',
+                        }}
+                      />
+                    </div>
+                  );
+                }
+
+                // 看书/吃饭:相对凳子锚点水平居中、底边对齐凳面附近
+                const gifLeft = anchorLeft + anchorWidth / 2 - gifSize / 2;
+                const gifTop = anchorTop + anchorHeight - gifSize + offsetY;
+
+                return (
+                  <img
+                    src={SPECIAL_SCENE_GIF[scene]}
+                    alt=""
+                    className="absolute pointer-events-none"
+                    loading="lazy"
+                    style={{
+                      width: px2vmin(gifSize),
+                      height: px2vmin(gifSize),
+                      left: px2vmin(gifLeft),
+                      top: px2vmin(gifTop),
+                      zIndex: avatarZ,
+                      objectFit: 'contain',
+                      objectPosition: 'center bottom',
+                    }}
+                    draggable={false}
+                  />
+                );
+              })()
+            )}
+        </div>
+      </div>
 
       {/* 说话时的阴影蒙层:仅录音中出现,发送/取消后立即消失 */}
       {currentState === 'recording' && (
@@ -3869,7 +3893,11 @@ export const ChatBotPage = () => {
                         key: 'shop',
                         label: tCommon('shop'),
                         icon: shopMenuIcon,
-                        enabled: false,
+                        enabled: true,
+                        onClick: wrapTrackedClick('chatbot', 'shop', () => {
+                          setShowFuncMenuPopup(false);
+                          navigate('/lumi-shop', { state: { from: '/chatbot' } });
+                        }),
                       },
                       {
                         key: 'world',

+ 175 - 138
frontend/src/pages/LumiOutfitsPage.tsx

@@ -1,6 +1,6 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { CSSProperties } from 'react';
-import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
@@ -86,6 +86,7 @@ type FurnitureActionModalMode = 'equip' | 'remove';
 
 export const LumiOutfitsPage = () => {
   const navigate = useNavigate();
+  const location = useLocation();
   const [searchParams] = useSearchParams();
   const { t, i18n } = useTranslation('common');
   const { t: tProfile } = useTranslation('profile');
@@ -161,8 +162,9 @@ export const LumiOutfitsPage = () => {
   }, [navigate]);
 
   const handleVisitStore = useCallback(() => {
-    navigate('/chatbot');
-  }, [navigate]);
+    const from = `${location.pathname}${location.search}`;
+    navigate('/lumi-shop', { state: { from } });
+  }, [navigate, location.pathname, location.search]);
 
   const onChatNowClick = useMemo(
     () => wrapTrackedClick('lumi_outfits', 'chat_now', handleChatNow),
@@ -287,9 +289,9 @@ export const LumiOutfitsPage = () => {
   const cellHeight = chatbotGrid.cell_height ?? 96;
   const gridRows = chatbotGrid.grid_rows ?? 5;
   const gridCols = chatbotGrid.grid_cols ?? 4;
-  const decorAreaWidth = chatbotGrid.decor_area_width ?? 393;
   const gridOriginY = 0;
-  const gridOriginX = (decorAreaWidth - gridCols * cellWidth) / 2;
+  /** 5×4 在幕布层内水平居中,槽位相对网格左上角 */
+  const gridOriginX = 0;
 
   const handleSelectFurnitureTab = useCallback(() => {
     setLumiBagTab('furniture');
@@ -317,11 +319,11 @@ export const LumiOutfitsPage = () => {
     [isZh],
   );
 
-  const showFurnitureNotice = useCallback((message: string) => {
+  const showFurnitureNotice = useCallback((message: string, durationMs = 2500) => {
     setFurnitureNotice(message);
     window.setTimeout(() => {
       setFurnitureNotice((current) => (current === message ? '' : current));
-    }, 2500);
+    }, durationMs);
   }, []);
 
   const handleFurnitureCardClick = useCallback(
@@ -350,20 +352,31 @@ export const LumiOutfitsPage = () => {
 
   const handleFurnitureModalConfirm = useCallback(() => {
     if (!activeFurnitureModalItem || furnitureSubmitting) return;
+    const itemSnapshot = activeFurnitureModalItem;
     setFurnitureSubmitting(true);
     const actionPromise =
       furnitureModalMode === 'remove'
-        ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, activeFurnitureModalItem.item_id))
-        : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, activeFurnitureModalItem.item_id));
+        ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, itemSnapshot.item_id))
+        : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, itemSnapshot.item_id));
 
     void actionPromise
       .then((res) => {
-        if (
-          furnitureModalMode === 'remove' &&
-          'data' in res &&
-          Number(res.data?.cascade_removed_count || 0) > 0
-        ) {
-          showFurnitureNotice(res.data?.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
+        if (furnitureModalMode === 'remove') {
+          const parts: string[] = [];
+          if ('data' in res && Number(res.data?.cascade_removed_count || 0) > 0) {
+            parts.push(res.data?.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
+          }
+          if (Number(itemSnapshot.has_special_scene_trigger || 0) > 0) {
+            const nameForLocale = isZh
+              ? itemSnapshot.name_zh || itemSnapshot.name || ''
+              : itemSnapshot.name_en || itemSnapshot.name || '';
+            parts.push(tProfile('lumiFurnitureRemovedSceneNotice', { name: nameForLocale }));
+          }
+          if (parts.length > 0) {
+            const combined = parts.join('\n\n');
+            const durationMs = parts.length > 1 || combined.length > 48 ? 4500 : 3500;
+            showFurnitureNotice(combined, durationMs);
+          }
         }
         return Promise.all([refreshChatbotLayout(), refreshOwnedFurnitureItems()]);
       })
@@ -388,11 +401,12 @@ export const LumiOutfitsPage = () => {
     activeFurnitureModalItem,
     furnitureModalMode,
     furnitureSubmitting,
+    isZh,
     refreshChatbotLayout,
     refreshOwnedFurnitureItems,
     showFurnitureNotice,
     t,
-      tProfile,
+    tProfile,
   ]);
 
   return (
@@ -406,146 +420,169 @@ export const LumiOutfitsPage = () => {
           overflow: 'hidden',
         }}
       >
-        {activeBackdropImageName && (
-          <div
-            className="absolute pointer-events-none"
-            style={{
-              width: '100%',
-              height: '100%',
-              left: 0,
-              top: 0,
-              zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
-            }}
-          >
-            <img
-              src={getOssUrl(activeBackdropImageName)}
-              alt={t('a11y.trees')}
-              className="absolute"
-              fetchPriority="low"
-              loading="lazy"
-              draggable={false}
+        <div
+          className="absolute pointer-events-none"
+          style={{
+            left: 0,
+            top: 0,
+            width: '100%',
+            height: '100%',
+            zIndex: 0,
+          }}
+        >
+          {activeBackdropImageName ? (
+            <div
+              className="absolute pointer-events-none"
               style={{
                 width: '100%',
                 height: '100%',
                 left: 0,
                 top: 0,
-                objectFit: 'cover',
+                zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
               }}
-            />
-          </div>
-        )}
+            >
+              <img
+                src={getOssUrl(activeBackdropImageName)}
+                alt={t('a11y.trees')}
+                className="absolute"
+                fetchPriority="low"
+                loading="lazy"
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  left: 0,
+                  top: 0,
+                  objectFit: 'cover',
+                }}
+              />
+            </div>
+          ) : null}
 
-        {lumiBagTab === 'furniture' && (
           <div
             className="absolute pointer-events-none"
             style={{
-              left: px2vmin(gridOriginX),
-              top: px2vmin(gridOriginY),
+              left: '50%',
+              transform: 'translateX(-50%)',
               width: px2vmin(gridCols * cellWidth),
-              height: px2vmin(gridRows * cellHeight),
-              zIndex: CHATBOT_Z_INDEX.GRID_LINES,
-              border: '1px solid rgba(255, 255, 255, 0.35)',
-              boxSizing: 'border-box',
+              top: 0,
+              bottom: 0,
+              zIndex: 1,
             }}
           >
-            {Array.from({ length: gridCols - 1 }, (_, idx) => {
-              const col = idx + 1;
-              return (
-                <div
-                  key={col}
-                  style={{
-                    position: 'absolute',
-                    top: 0,
-                    left: px2vmin(col * cellWidth),
-                    width: 1,
-                    height: '100%',
-                    backgroundColor: 'rgba(255, 255, 255, 0.35)',
-                  }}
-                />
-              );
-            })}
-            {Array.from({ length: gridRows - 1 }, (_, idx) => {
-              const row = idx + 1;
+            {lumiBagTab === 'furniture' && (
+              <div
+                className="absolute pointer-events-none"
+                style={{
+                  left: px2vmin(gridOriginX),
+                  top: px2vmin(gridOriginY),
+                  width: px2vmin(gridCols * cellWidth),
+                  height: px2vmin(gridRows * cellHeight),
+                  zIndex: CHATBOT_Z_INDEX.GRID_LINES,
+                  border: '1px solid rgba(255, 255, 255, 0.35)',
+                  boxSizing: 'border-box',
+                }}
+              >
+                {Array.from({ length: gridCols - 1 }, (_, idx) => {
+                  const col = idx + 1;
+                  return (
+                    <div
+                      key={col}
+                      style={{
+                        position: 'absolute',
+                        top: 0,
+                        left: px2vmin(col * cellWidth),
+                        width: 1,
+                        height: '100%',
+                        backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                      }}
+                    />
+                  );
+                })}
+                {Array.from({ length: gridRows - 1 }, (_, idx) => {
+                  const row = idx + 1;
+                  return (
+                    <div
+                      key={row}
+                      style={{
+                        position: 'absolute',
+                        left: 0,
+                        top: px2vmin(row * cellHeight),
+                        height: 1,
+                        width: '100%',
+                        backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                      }}
+                    />
+                  );
+                })}
+              </div>
+            )}
+
+            {activeLayoutItems.map((item, idx) => {
+              const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
+              const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
+              const slotWidthPx = item.size_cols * cellWidth;
+              const slotHeightPx = item.size_rows * cellHeight;
+              const isPixelSlot = item.render_mode === 'pixel';
+              const offsetX = item.offset_x ?? 0;
+              const offsetY = item.offset_y ?? 0;
+              const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
+              const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
+              const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+              const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
               return (
-                <div
-                  key={row}
+                <img
+                  key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
+                  src={getOssUrl(item.image_name_snapshot)}
+                  alt=""
+                  className="absolute pointer-events-none"
+                  fetchPriority="low"
+                  loading="lazy"
+                  draggable={false}
                   style={{
-                    position: 'absolute',
-                    left: 0,
-                    top: px2vmin(row * cellHeight),
-                    height: 1,
-                    width: '100%',
-                    backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                    width: px2vmin(widthPx),
+                    height: px2vmin(heightPx),
+                    left: px2vmin(leftPx),
+                    top: px2vmin(topPx),
+                    zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
+                    objectFit: 'contain',
                   }}
                 />
               );
             })}
-          </div>
-        )}
 
-        {activeLayoutItems.map((item, idx) => {
-          const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
-          const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
-          const slotWidthPx = item.size_cols * cellWidth;
-          const slotHeightPx = item.size_rows * cellHeight;
-          const isPixelSlot = item.render_mode === 'pixel';
-          const offsetX = item.offset_x ?? 0;
-          const offsetY = item.offset_y ?? 0;
-          const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
-          const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
-          const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
-          const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
-          return (
-            <img
-              key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
-              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),
-                zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
-                objectFit: 'contain',
-              }}
-            />
-          );
-        })}
-
-        {lumiBagTab === 'furniture' &&
-          chatbotBackdropSlots.map((slot, idx) => {
-            const slotLeftPx = gridOriginX + (slot.start_col - 1) * cellWidth;
-            const slotTopPx = gridOriginY + (slot.start_row - 1) * cellHeight;
-            const slotWidthPx = slot.size_cols * cellWidth;
-            const slotHeightPx = slot.size_rows * cellHeight;
-            const isPixelSlot = slot.render_mode === 'pixel';
-            const offsetX = slot.offset_x ?? 0;
-            const offsetY = slot.offset_y ?? 0;
-            const widthPx = isPixelSlot ? (slot.pixel_width ?? slotWidthPx) : slotWidthPx;
-            const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
-            const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
-            const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
-            const color = SLOT_HIGHLIGHT_COLORS[idx % SLOT_HIGHLIGHT_COLORS.length];
-            return (
-              <div
-                key={`slot-highlight-${slot.id}-${idx}`}
-                className="absolute pointer-events-none"
-                style={{
-                  left: px2vmin(leftPx),
-                  top: px2vmin(topPx),
-                  width: px2vmin(widthPx),
-                  height: px2vmin(heightPx),
-                  border: `2PX solid ${color}`,
-                  boxSizing: 'border-box',
-                  zIndex: 20,
-                }}
-              />
-            );
-          })}
+            {lumiBagTab === 'furniture' &&
+              chatbotBackdropSlots.map((slot, idx) => {
+                const slotLeftPx = gridOriginX + (slot.start_col - 1) * cellWidth;
+                const slotTopPx = gridOriginY + (slot.start_row - 1) * cellHeight;
+                const slotWidthPx = slot.size_cols * cellWidth;
+                const slotHeightPx = slot.size_rows * cellHeight;
+                const isPixelSlot = slot.render_mode === 'pixel';
+                const offsetX = slot.offset_x ?? 0;
+                const offsetY = slot.offset_y ?? 0;
+                const widthPx = isPixelSlot ? (slot.pixel_width ?? slotWidthPx) : slotWidthPx;
+                const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
+                const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+                const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
+                const color = SLOT_HIGHLIGHT_COLORS[idx % SLOT_HIGHLIGHT_COLORS.length];
+                return (
+                  <div
+                    key={`slot-highlight-${slot.id}-${idx}`}
+                    className="absolute pointer-events-none"
+                    style={{
+                      left: px2vmin(leftPx),
+                      top: px2vmin(topPx),
+                      width: px2vmin(widthPx),
+                      height: px2vmin(heightPx),
+                      border: `2PX solid ${color}`,
+                      boxSizing: 'border-box',
+                      zIndex: 20,
+                    }}
+                  />
+                );
+              })}
+          </div>
+        </div>
 
         {lumiBagTab === 'outfits' && (
           <img
@@ -569,7 +606,7 @@ export const LumiOutfitsPage = () => {
           type="button"
           onClick={onBackClick}
           style={{
-            position: 'fixed',
+            position: 'absolute',
             left: px2vmin(24),
             top: px2vmin(125),
             width: px2vmin(32),
@@ -976,7 +1013,7 @@ export const LumiOutfitsPage = () => {
                     WebkitOverflowScrolling: 'touch',
                     display: 'grid',
                     gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))',
-                    gap: px2vmin(20),
+                    gap: px2vmin(10),
                     alignContent: 'start',
                     paddingBottom: px2vmin(2),
                     boxSizing: 'border-box',

+ 1517 - 0
frontend/src/pages/LumiShopPage.tsx

@@ -0,0 +1,1517 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { px2vmin, px2vw } from '@/utils/viewport';
+import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
+import { onScreenResize, updateTooSmallFlag } from '@/utils/screenSize';
+import {
+  ApiError,
+  ensureSession,
+  equipChatbotFurniture,
+  getChatbotLayout,
+  getUserInfo,
+  getChatbotShopCatalog,
+  type ChatbotLayoutBackdrop,
+  type ChatbotLayoutItem,
+  type ChatbotBackdropSlot,
+  type ChatbotShopCatalogItem,
+  unequipChatbotFurniture,
+  updateChatbotBackdrop,
+} from '@/utils/api';
+import '@/styles/chatbot-fix.css';
+
+const defaultBackdropImageName = 'init3.png';
+const backButtonIcon = getOssUrl('backbutn-icon.png');
+const lumiOutfit1ShopImg = getOssUrl('Lumi-outfit-1.png');
+const secondaryDefaultButton = getOssUrl('secondary-default.png');
+const primaryDefaultButton = getOssUrl('primary-default.png');
+/** 与 Profile Lumi 背包「家具 / 服装」卡片一致 */
+const chairBagImg = getOssUrl('Chair.png');
+const glovesBagImg = getOssUrl('Gloves.png');
+const bagAllImg = getOssUrl('bag-all.svg');
+const bagTableCabinetImg = getOssUrl('bag_table_cabinet.svg');
+const bagWindowImg = getOssUrl('bag-window.svg');
+const bagSeatSofaImg = getOssUrl('bag-seat_sofa.svg');
+const bagWallpaperImg = getOssUrl('bag-wallpaper.svg');
+const bagPropImg = getOssUrl('bag-prop.svg');
+const shopBagIcon = getOssUrl('Icon-shopbag.svg');
+const conversationEnergyStoneCount = getOssUrl('conversion-energy-stone-count.png');
+const missionEnergyStoneCountImg = getOssUrl('mission-energy-stone-count.png');
+
+type FurnitureFilterKey =
+  | 'all'
+  | 'table_cabinet'
+  | 'window'
+  | 'seat_sofa'
+  | 'wallpaper'
+  | 'prop';
+
+const FURNITURE_FILTERS: Array<{
+  key: FurnitureFilterKey;
+  icon: string;
+  ariaLabel: string;
+}> = [
+  { key: 'all', icon: bagAllImg, ariaLabel: 'all furniture' },
+  { key: 'table_cabinet', icon: bagTableCabinetImg, ariaLabel: 'table or cabinet' },
+  { key: 'window', icon: bagWindowImg, ariaLabel: 'window' },
+  { key: 'seat_sofa', icon: bagSeatSofaImg, ariaLabel: 'seat or sofa' },
+  { key: 'wallpaper', icon: bagWallpaperImg, ariaLabel: 'wallpaper' },
+  { key: 'prop', icon: bagPropImg, ariaLabel: 'other furniture' },
+];
+
+const CHATBOT_Z_INDEX = {
+  GRID_BACKDROP: 0,
+  GRID_LINES: 1,
+  GRID_DYNAMIC_MIN: 3,
+} as const;
+const SLOT_HIGHLIGHT_COLORS = [
+  '#00FF62',
+  '#FFB020',
+  '#4DA3FF',
+  '#FF5FA2',
+  '#A56BFF',
+  '#00D1FF',
+  '#FFD60A',
+  '#FF6B6B',
+] as const;
+
+type FurnitureActionModalMode = 'equip' | 'remove';
+
+/** 仅允许应用内相对路径,避免 open redirect */
+function parseShopReturnPath(state: unknown): string | null {
+  if (!state || typeof state !== 'object') return null;
+  const from = (state as { from?: unknown }).from;
+  if (typeof from !== 'string' || from.length === 0 || from.length > 512) return null;
+  if (!from.startsWith('/') || from.startsWith('//')) return null;
+  if (from.includes('://')) return null;
+  return from.split('#')[0] || null;
+}
+
+export const LumiShopPage = () => {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const [searchParams] = useSearchParams();
+  const { t, i18n } = useTranslation('common');
+  const { t: tProfile } = useTranslation('profile');
+
+  useEffect(() => {
+    updateTooSmallFlag();
+    return onScreenResize(() => {
+      updateTooSmallFlag();
+    });
+  }, []);
+
+  const handleBack = useCallback(() => {
+    const target = parseShopReturnPath(location.state);
+    if (target) {
+      navigate(target);
+      return;
+    }
+    navigate(-1);
+  }, [navigate, location.state]);
+
+  const onBackClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'back', handleBack),
+    [handleBack],
+  );
+
+  const handleChatNow = useCallback(() => {
+    navigate('/chatbot');
+  }, [navigate]);
+
+  const onChatNowClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'chat_now', handleChatNow),
+    [handleChatNow],
+  );
+
+  const handlePurchaseGoChat = useCallback(() => {
+    setActivePurchaseItem(null);
+    navigate('/chatbot');
+  }, [navigate]);
+
+  const onPurchaseGoChatClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'purchase_go_chat', handlePurchaseGoChat),
+    [handlePurchaseGoChat],
+  );
+
+  const [lumiBagTab, setLumiBagTab] = useState<'furniture' | 'outfits'>('furniture');
+  const [furnitureFilter, setFurnitureFilter] = useState<FurnitureFilterKey>('all');
+  const [furnitureCatalogItems, setFurnitureCatalogItems] = useState<ChatbotShopCatalogItem[]>([]);
+  const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
+  const [crystalStoneCount, setCrystalStoneCount] = useState<number>(0);
+  const [chatbotBackdrop, setChatbotBackdrop] = useState<ChatbotLayoutBackdrop | null>(null);
+  /** 首次拉取布局完成前不渲染默认幕布 init3,避免与真实幕布切换时产生「窗户闪一下」 */
+  const [chatbotLayoutReady, setChatbotLayoutReady] = useState(false);
+  const [chatbotLayoutItems, setChatbotLayoutItems] = useState<ChatbotLayoutItem[]>([]);
+  const [chatbotBackdropSlots, setChatbotBackdropSlots] = useState<ChatbotBackdropSlot[]>([]);
+  const [furnitureNotice, setFurnitureNotice] = useState<string>('');
+  const [activePurchaseItem, setActivePurchaseItem] = useState<ChatbotShopCatalogItem | null>(null);
+  const [activeFurnitureModalItem, setActiveFurnitureModalItem] = useState<ChatbotShopCatalogItem | null>(null);
+  const [furnitureModalMode, setFurnitureModalMode] = useState<FurnitureActionModalMode>('equip');
+  const [furnitureSubmitting, setFurnitureSubmitting] = useState<boolean>(false);
+  const [chatbotGrid, setChatbotGrid] = useState(() => ({
+    grid_rows: 5,
+    grid_cols: 4,
+    cell_width: 96,
+    cell_height: 96,
+    decor_area_width: 393,
+    decor_area_height: 482,
+  }));
+
+  useEffect(() => {
+    const tab = searchParams.get('tab');
+    setLumiBagTab(tab === 'outfits' ? 'outfits' : 'furniture');
+  }, [searchParams]);
+
+  useEffect(() => {
+    let mounted = true;
+    void ensureSession()
+      .then(({ userId }) => Promise.all([getChatbotShopCatalog(userId), getUserInfo(userId)]))
+      .then(([catalogRes, userInfoRes]) => {
+        if (!mounted) return;
+        if (catalogRes.success) {
+          setFurnitureCatalogItems(catalogRes.data || []);
+        }
+        if (userInfoRes.success && userInfoRes.data) {
+          setConversationStoneCount(userInfoRes.data.conversation_energy_stone_total ?? 0);
+          setCrystalStoneCount(userInfoRes.data.crystal_stone_total ?? 0);
+        }
+      })
+      .catch(() => {
+        if (!mounted) return;
+        setFurnitureCatalogItems([]);
+      });
+    return () => {
+      mounted = false;
+    };
+  }, []);
+
+  const refreshChatbotLayout = useCallback(async () => {
+    try {
+      const { userId } = await ensureSession();
+      const res = await getChatbotLayout(userId);
+      if (!res.success || !res.data) return;
+      setChatbotBackdrop(res.data.backdrop ?? null);
+      setChatbotLayoutItems(res.data.items ?? []);
+      setChatbotBackdropSlots(res.data.slots ?? []);
+      setChatbotGrid({
+        grid_rows: res.data.grid_rows,
+        grid_cols: res.data.grid_cols,
+        cell_width: res.data.cell_width,
+        cell_height: res.data.cell_height,
+        decor_area_width: res.data.decor_area_width,
+        decor_area_height: res.data.decor_area_height,
+      });
+    } finally {
+      setChatbotLayoutReady(true);
+    }
+  }, []);
+
+  const refreshFurnitureCatalog = useCallback(async () => {
+    const { userId } = await ensureSession();
+    const res = await getChatbotShopCatalog(userId);
+    if (!res.success) return;
+    setFurnitureCatalogItems(res.data || []);
+  }, []);
+
+  useEffect(() => {
+    let mounted = true;
+    void refreshChatbotLayout().catch(() => {
+      if (!mounted) return;
+      setChatbotBackdrop(null);
+      setChatbotLayoutItems([]);
+      setChatbotBackdropSlots([]);
+    });
+    return () => {
+      mounted = false;
+    };
+  }, [refreshChatbotLayout]);
+
+  const filteredFurnitureCatalogItems = useMemo(() => {
+    if (furnitureFilter === 'all') return furnitureCatalogItems;
+    return furnitureCatalogItems.filter((item) => item.biz_type === furnitureFilter);
+  }, [furnitureFilter, furnitureCatalogItems]);
+  const activeBackdropImageName =
+    chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
+  const activeLayoutItems = chatbotLayoutItems;
+  const cellWidth = chatbotGrid.cell_width ?? 96;
+  const cellHeight = chatbotGrid.cell_height ?? 96;
+  const gridRows = chatbotGrid.grid_rows ?? 5;
+  const gridCols = chatbotGrid.grid_cols ?? 4;
+  const gridOriginY = 0;
+  /** 5×4 区整体在幕布层水平居中,槽位坐标相对网格左上角 */
+  const gridOriginX = 0;
+
+  const handleSelectFurnitureTab = useCallback(() => {
+    setLumiBagTab('furniture');
+  }, []);
+
+  const handleSelectOutfitsTab = useCallback(() => {
+    setLumiBagTab('outfits');
+  }, []);
+
+  const onFurnitureTabClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'bag_furniture_tab', handleSelectFurnitureTab),
+    [handleSelectFurnitureTab],
+  );
+
+  const onOutfitsTabClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'bag_outfits_tab', handleSelectOutfitsTab),
+    [handleSelectOutfitsTab],
+  );
+
+  const handleShopCartClick = useCallback(() => {
+    // 购物车页/流程待接入
+  }, []);
+
+  const onShopCartClick = useMemo(
+    () => wrapTrackedClick('lumi_shop', 'shop_cart', handleShopCartClick),
+    [handleShopCartClick],
+  );
+
+  const isZh = i18n.resolvedLanguage?.toLowerCase().startsWith('zh') ?? true;
+  const getCatalogItemDisplayName = useCallback(
+    (item: ChatbotShopCatalogItem) => {
+      return isZh ? item.name_zh || item.name || '' : item.name_en || item.name || '';
+    },
+    [isZh],
+  );
+
+  const formatCatalogEnergyPrice = useCallback(
+    (item: ChatbotShopCatalogItem) => {
+      const pe = item.price_energy ?? 0;
+      if (pe <= 0) return tProfile('lumiShopPriceFree');
+      const s = Number.isInteger(pe) ? String(pe) : pe.toFixed(2);
+      return tProfile('lumiShopPriceEnergy', { price: s });
+    },
+    [tProfile],
+  );
+
+  const formatCatalogEnergyCount = useCallback((item: ChatbotShopCatalogItem) => {
+    const pe = Number(item.price_energy ?? 0);
+    if (pe <= 0) return '0';
+    return Number.isInteger(pe) ? String(pe) : pe.toFixed(2);
+  }, []);
+
+  const showFurnitureNotice = useCallback((message: string) => {
+    setFurnitureNotice(message);
+    window.setTimeout(() => {
+      setFurnitureNotice((current) => (current === message ? '' : current));
+    }, 2500);
+  }, []);
+
+  const handleFurnitureCardClick = useCallback(
+    (item: ChatbotShopCatalogItem) => {
+      if (!item.is_owned) {
+        setActivePurchaseItem(item);
+        return;
+      }
+
+      const isBackdrop = item.item_type === 'backdrop' || item.biz_type === 'wallpaper';
+      if (isBackdrop && item.is_in_use) {
+        showFurnitureNotice(tProfile('lumiWallpaperFixedNotice'));
+        return;
+      }
+
+      if (isBackdrop && !item.is_in_use) {
+        void ensureSession()
+          .then(({ userId }) => updateChatbotBackdrop(userId, item.item_id))
+          .then(() => Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]))
+          .catch(() => {
+            showFurnitureNotice(t('error.SERVER_ERROR'));
+          });
+        return;
+      }
+
+      setActiveFurnitureModalItem(item);
+      setFurnitureModalMode(item.is_in_use ? 'remove' : 'equip');
+    },
+    [refreshChatbotLayout, refreshFurnitureCatalog, showFurnitureNotice, t, tProfile],
+  );
+
+  const handleFurnitureModalConfirm = useCallback(() => {
+    if (!activeFurnitureModalItem || furnitureSubmitting) return;
+    setFurnitureSubmitting(true);
+    const actionPromise =
+      furnitureModalMode === 'remove'
+        ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, activeFurnitureModalItem.item_id))
+        : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, activeFurnitureModalItem.item_id));
+
+    void actionPromise
+      .then((res) => {
+        if (
+          furnitureModalMode === 'remove' &&
+          res.success &&
+          res.data &&
+          'cascade_removed_count' in res.data &&
+          Number(res.data.cascade_removed_count || 0) > 0
+        ) {
+          showFurnitureNotice(res.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
+        }
+        return Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]);
+      })
+      .then(() => {
+        setActiveFurnitureModalItem(null);
+      })
+      .catch((error: unknown) => {
+        if (error instanceof ApiError) {
+          if (error.code === 'SLOT_DEPENDENCY_NOT_SATISFIED') {
+            showFurnitureNotice(error.backendMessage || tProfile('lumiFurnitureDependencyNotMetNotice'));
+            return;
+          }
+          showFurnitureNotice(t(`error.${error.code}`));
+          return;
+        }
+        showFurnitureNotice(t('error.SERVER_ERROR'));
+      })
+      .finally(() => {
+        setFurnitureSubmitting(false);
+      });
+  }, [
+    activeFurnitureModalItem,
+    furnitureModalMode,
+    furnitureSubmitting,
+    refreshChatbotLayout,
+    refreshFurnitureCatalog,
+    showFurnitureNotice,
+    t,
+      tProfile,
+  ]);
+
+  return (
+    <>
+      <div
+        className="chatbot-container"
+        style={{
+          position: 'relative',
+          width: '100%',
+          minHeight: '100dvh',
+          overflow: 'hidden',
+        }}
+      >
+        {/* 幕布舞台:与背景图同域的包含块,5×4 区相对本层(幕布区域)水平居中 */}
+        <div
+          className="absolute pointer-events-none"
+          style={{
+            left: 0,
+            top: 0,
+            width: '100%',
+            height: '100%',
+            zIndex: 0,
+          }}
+        >
+          {activeBackdropImageName ? (
+            <div
+              className="absolute pointer-events-none"
+              style={{
+                width: '100%',
+                height: '100%',
+                left: 0,
+                top: 0,
+                zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
+              }}
+            >
+              <img
+                src={getOssUrl(activeBackdropImageName)}
+                alt={t('a11y.trees')}
+                className="absolute"
+                fetchPriority="low"
+                loading="lazy"
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  left: 0,
+                  top: 0,
+                  objectFit: 'cover',
+                }}
+              />
+            </div>
+          ) : null}
+
+          {/* 5×4 单元格区域:宽=列数×格宽,相对幕布舞台水平居中;与格线/家具同一 vmin 基准 */}
+          <div
+            className="absolute pointer-events-none"
+            style={{
+              left: '50%',
+              transform: 'translateX(-50%)',
+              width: px2vmin(gridCols * cellWidth),
+              top: 0,
+              bottom: 0,
+              zIndex: 1,
+            }}
+          >
+          {lumiBagTab === 'furniture' && (
+            <div
+              className="absolute pointer-events-none"
+              style={{
+                left: px2vmin(gridOriginX),
+                top: px2vmin(gridOriginY),
+                width: px2vmin(gridCols * cellWidth),
+                height: px2vmin(gridRows * cellHeight),
+                zIndex: CHATBOT_Z_INDEX.GRID_LINES,
+                border: '1px solid rgba(255, 255, 255, 0.35)',
+                boxSizing: 'border-box',
+              }}
+            >
+              {Array.from({ length: gridCols - 1 }, (_, idx) => {
+                const col = idx + 1;
+                return (
+                  <div
+                    key={col}
+                    style={{
+                      position: 'absolute',
+                      top: 0,
+                      left: px2vmin(col * cellWidth),
+                      width: 1,
+                      height: '100%',
+                      backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                    }}
+                  />
+                );
+              })}
+              {Array.from({ length: gridRows - 1 }, (_, idx) => {
+                const row = idx + 1;
+                return (
+                  <div
+                    key={row}
+                    style={{
+                      position: 'absolute',
+                      left: 0,
+                      top: px2vmin(row * cellHeight),
+                      height: 1,
+                      width: '100%',
+                      backgroundColor: 'rgba(255, 255, 255, 0.35)',
+                    }}
+                  />
+                );
+              })}
+            </div>
+          )}
+
+          {activeLayoutItems.map((item, idx) => {
+            const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
+            const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
+            const slotWidthPx = item.size_cols * cellWidth;
+            const slotHeightPx = item.size_rows * cellHeight;
+            const isPixelSlot = item.render_mode === 'pixel';
+            const offsetX = item.offset_x ?? 0;
+            const offsetY = item.offset_y ?? 0;
+            const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
+            const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
+            const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+            const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
+            return (
+              <img
+                key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
+                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),
+                  zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
+                  objectFit: 'contain',
+                }}
+              />
+            );
+          })}
+
+          {lumiBagTab === 'furniture' &&
+            chatbotBackdropSlots.map((slot, idx) => {
+              const slotLeftPx = gridOriginX + (slot.start_col - 1) * cellWidth;
+              const slotTopPx = gridOriginY + (slot.start_row - 1) * cellHeight;
+              const slotWidthPx = slot.size_cols * cellWidth;
+              const slotHeightPx = slot.size_rows * cellHeight;
+              const isPixelSlot = slot.render_mode === 'pixel';
+              const offsetX = slot.offset_x ?? 0;
+              const offsetY = slot.offset_y ?? 0;
+              const widthPx = isPixelSlot ? (slot.pixel_width ?? slotWidthPx) : slotWidthPx;
+              const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
+              const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+              const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
+              const color = SLOT_HIGHLIGHT_COLORS[idx % SLOT_HIGHLIGHT_COLORS.length];
+              return (
+                <div
+                  key={`slot-highlight-${slot.id}-${idx}`}
+                  className="absolute pointer-events-none"
+                  style={{
+                    left: px2vmin(leftPx),
+                    top: px2vmin(topPx),
+                    width: px2vmin(widthPx),
+                    height: px2vmin(heightPx),
+                    border: `2PX solid ${color}`,
+                    boxSizing: 'border-box',
+                    zIndex: 20,
+                  }}
+                />
+              );
+            })}
+          </div>
+        </div>
+
+        <button
+          type="button"
+          onClick={onBackClick}
+          style={{
+            position: 'absolute',
+            left: px2vmin(24),
+            top: px2vmin(125),
+            width: px2vmin(32),
+            height: px2vmin(32),
+            padding: 0,
+            border: 'none',
+            background: 'none',
+            borderRadius: '50%',
+            zIndex: 100,
+            cursor: 'pointer',
+          }}
+          aria-label={t('a11y.back')}
+        >
+          <img
+            src={backButtonIcon}
+            alt=""
+            style={{
+              width: '100%',
+              height: '100%',
+              display: 'block',
+              pointerEvents: 'none',
+            }}
+            draggable={false}
+          />
+        </button>
+      </div>
+
+      {furnitureNotice ? (
+        <div
+          style={{
+            position: 'fixed',
+            left: '50%',
+            transform: 'translateX(-50%)',
+            bottom: px2vmin(365),
+            width: px2vmin(312),
+            minHeight: px2vmin(60),
+            maxHeight: px2vmin(120),
+            backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            borderRadius: px2vmin(12),
+            display: 'flex',
+            alignItems: 'flex-start',
+            justifyContent: 'center',
+            textAlign: 'center',
+            color: '#fff',
+            fontSize: px2vmin(14),
+            lineHeight: px2vmin(20),
+            padding: `${px2vmin(10)} ${px2vmin(10)}`,
+            boxSizing: 'border-box',
+            zIndex: 10020,
+            overflowY: 'auto',
+          }}
+        >
+          {furnitureNotice}
+        </div>
+      ) : null}
+
+      {/* 底部弹窗 + 上方家具/服装切换(与 Profile Lumi 背包 icon 一致) */}
+      <div
+        style={{
+          position: 'fixed',
+          bottom: 0,
+          left: '50%',
+          transform: 'translateX(-50%)',
+          width: px2vw(393),
+          zIndex: 9999,
+          boxSizing: 'border-box',
+        }}
+      >
+        <div
+          style={{
+            paddingLeft: px2vmin(24),
+            paddingRight: px2vmin(24),
+            marginBottom: px2vmin(10),
+            display: 'flex',
+            flexDirection: 'row',
+            alignItems: 'center',
+            justifyContent: 'space-between',
+            boxSizing: 'border-box',
+          }}
+        >
+          <div
+            style={{
+              display: 'flex',
+              flexDirection: 'row',
+              alignItems: 'center',
+              gap: px2vmin(3),
+              flexShrink: 0,
+            }}
+          >
+            <div
+              style={{
+                position: 'relative',
+                width: px2vmin(82),
+                height: px2vmin(32),
+                flexShrink: 0,
+              }}
+            >
+              <img
+                src={conversationEnergyStoneCount}
+                alt=""
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  display: 'block',
+                  objectFit: 'contain',
+                }}
+              />
+              <span
+                style={{
+                  position: 'absolute',
+                  top: '50%',
+                  left: '63%',
+                  transform: 'translate(-50%, -50%)',
+                  fontSize: px2vmin(10),
+                  color: '#000',
+                  fontWeight: 500,
+                }}
+              >
+                {conversationStoneCount}
+              </span>
+            </div>
+            <div
+              style={{
+                position: 'relative',
+                width: px2vmin(82),
+                height: px2vmin(32),
+                flexShrink: 0,
+              }}
+            >
+              <img
+                src={missionEnergyStoneCountImg}
+                alt=""
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  display: 'block',
+                  objectFit: 'contain',
+                }}
+              />
+              <span
+                style={{
+                  position: 'absolute',
+                  top: '50%',
+                  left: '63%',
+                  transform: 'translate(-50%, -50%)',
+                  fontSize: px2vmin(10),
+                  color: '#000',
+                  fontWeight: 500,
+                }}
+              >
+                {crystalStoneCount}
+              </span>
+            </div>
+            <div
+              role="tablist"
+              aria-label={tProfile('lumiBagTitle')}
+              style={{
+                width: px2vmin(80),
+                height: px2vmin(32),
+                borderRadius: px2vmin(12),
+                backgroundColor: 'rgba(0, 0, 0, 0.2)',
+                boxSizing: 'border-box',
+                display: 'flex',
+                flexDirection: 'row',
+                alignItems: 'stretch',
+                padding: px2vmin(2),
+                gap: px2vmin(2),
+                flexShrink: 0,
+              }}
+            >
+            <button
+              type="button"
+              role="tab"
+              aria-selected={lumiBagTab === 'furniture'}
+              onClick={onFurnitureTabClick}
+              style={{
+                flex: 1,
+                minWidth: 0,
+                margin: 0,
+                padding: 0,
+                border: 'none',
+                cursor: 'pointer',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                borderRadius: px2vmin(10),
+                backgroundColor:
+                  lumiBagTab === 'furniture' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              aria-label={tProfile('lumiBagFurniture')}
+            >
+              <img
+                src={chairBagImg}
+                alt=""
+                draggable={false}
+                style={{
+                  width: px2vmin(20),
+                  height: px2vmin(20),
+                  objectFit: 'contain',
+                  display: 'block',
+                  pointerEvents: 'none',
+                }}
+              />
+            </button>
+            <button
+              type="button"
+              role="tab"
+              aria-selected={lumiBagTab === 'outfits'}
+              onClick={onOutfitsTabClick}
+              style={{
+                flex: 1,
+                minWidth: 0,
+                margin: 0,
+                padding: 0,
+                border: 'none',
+                cursor: 'pointer',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                borderRadius: px2vmin(10),
+                backgroundColor:
+                  lumiBagTab === 'outfits' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              aria-label={tProfile('lumiBagOutfits')}
+            >
+              <img
+                src={glovesBagImg}
+                alt=""
+                draggable={false}
+                style={{
+                  width: px2vmin(20),
+                  height: px2vmin(20),
+                  objectFit: 'contain',
+                  display: 'block',
+                  pointerEvents: 'none',
+                }}
+              />
+            </button>
+          </div>
+          </div>
+          <button
+            type="button"
+            onClick={onShopCartClick}
+            aria-label={tProfile('lumiShopCartA11y')}
+            style={{
+              flexShrink: 0,
+              margin: 0,
+              padding: 0,
+              border: 'none',
+              background: 'none',
+              cursor: 'pointer',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              width: px2vmin(32),
+              height: px2vmin(32),
+              WebkitTapHighlightColor: 'transparent',
+            }}
+          >
+            <img
+              src={shopBagIcon}
+              alt=""
+              draggable={false}
+              style={{
+                width: px2vmin(32),
+                height: px2vmin(32),
+                objectFit: 'contain',
+                display: 'block',
+                pointerEvents: 'none',
+              }}
+            />
+          </button>
+        </div>
+
+        <div
+          style={{
+            height: px2vmin(318),
+            backgroundColor: '#34313D',
+            borderTopLeftRadius: px2vmin(32),
+            borderTopRightRadius: px2vmin(32),
+            boxSizing: 'border-box',
+            paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 0px)',
+          }}
+        >
+        <div
+          style={{
+            position: 'relative',
+            width: '100%',
+            height: '100%',
+            overflow: 'hidden',
+          }}
+        >
+          {/* 服装 tab:提示文案 + 去聊天(家具商店 tab 不占用此区,把空间留给商品列表) */}
+          {lumiBagTab === 'outfits' ? (
+            <div
+              style={{
+                position: 'absolute',
+                top: px2vmin(204),
+                left: 0,
+                right: 0,
+                display: 'flex',
+                flexDirection: 'column',
+                alignItems: 'center',
+                paddingLeft: px2vmin(32),
+                paddingRight: px2vmin(32),
+                boxSizing: 'border-box',
+                zIndex: 10040,
+              }}
+            >
+              <p
+                style={{
+                  margin: 0,
+                  width: '100%',
+                  color: '#FFFFFF',
+                  fontSize: px2vmin(14),
+                  lineHeight: px2vmin(14),
+                  textAlign: 'center',
+                  fontWeight: 400,
+                }}
+              >
+                {tProfile('lumiOutfitsShopHint')}
+              </p>
+              <div
+                style={{
+                  marginTop: px2vmin(10),
+                  display: 'flex',
+                  flexDirection: 'row',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  columnGap: px2vmin(20),
+                  width: '100%',
+                }}
+              >
+                <button
+                  type="button"
+                  onClick={onChatNowClick}
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter' || e.key === ' ') {
+                      e.preventDefault();
+                      onChatNowClick();
+                    }
+                  }}
+                  style={{
+                    width: px2vmin(312),
+                    height: px2vmin(56),
+                    padding: 0,
+                    border: 'none',
+                    borderRadius: px2vmin(12),
+                    background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+                    cursor: 'pointer',
+                    transition: 'opacity 0.2s ease',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontFamily:
+                      '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                    fontWeight: 500,
+                    fontSize: px2vmin(16),
+                    lineHeight: px2vmin(24),
+                    color: '#fff',
+                    WebkitTapHighlightColor: 'transparent',
+                  }}
+                >
+                  {tProfile('chatNowButton')}
+                </button>
+              </div>
+            </div>
+          ) : null}
+
+          {/* 顶部 44:与能量石弹窗一致留出手势条区域 */}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              right: 0,
+              height: px2vmin(44),
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              pointerEvents: 'none',
+            }}
+          >
+            <div
+              style={{
+                width: px2vmin(32),
+                height: px2vmin(4),
+                backgroundColor: '#79747E',
+                borderRadius: px2vmin(4),
+              }}
+            />
+          </div>
+
+          {/* 距弹窗顶部 44、高度 274(随弹窗总高 318) */}
+          <div
+            style={{
+              position: 'absolute',
+              top: px2vmin(44),
+              left: 0,
+              width: '100%',
+              height: px2vmin(274),
+              boxSizing: 'border-box',
+            }}
+          >
+            {/** 服装行:距父区域顶部 32,宽 310,高 90,水平居中 */}
+            {lumiBagTab === 'furniture' ? (
+              <div
+                style={{
+                  marginTop: px2vmin(12),
+                  marginLeft: 'auto',
+                  marginRight: 'auto',
+                  width: px2vmin(310),
+                  height: px2vmin(262),
+                  boxSizing: 'border-box',
+                  display: 'flex',
+                  flexDirection: 'column',
+                  minHeight: 0,
+                }}
+              >
+                <div
+                  style={{
+                    width: px2vmin(310),
+                    height: px2vmin(40),
+                    borderRadius: px2vmin(12),
+                    backgroundColor: 'rgba(245, 243, 240, 0.1)',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'space-between',
+                    gap: px2vmin(2),
+                    padding: 0,
+                    boxSizing: 'border-box',
+                    flexShrink: 0,
+                  }}
+                >
+                  {FURNITURE_FILTERS.map((filter) => {
+                    const selected = furnitureFilter === filter.key;
+                    return (
+                      <button
+                        key={filter.key}
+                        type="button"
+                        onClick={() => setFurnitureFilter(filter.key)}
+                        aria-label={filter.ariaLabel}
+                        style={{
+                          width: px2vmin(44),
+                          height: px2vmin(36),
+                          borderRadius: px2vmin(12),
+                          border: 'none',
+                          padding: 0,
+                          margin: 0,
+                          display: 'flex',
+                          alignItems: 'center',
+                          justifyContent: 'center',
+                          backgroundColor: selected ? '#8F6CC9' : 'transparent',
+                          cursor: 'pointer',
+                          WebkitTapHighlightColor: 'transparent',
+                        }}
+                      >
+                        <img
+                          src={filter.icon}
+                          alt=""
+                          draggable={false}
+                          style={{
+                            width: px2vmin(16),
+                            height: px2vmin(16),
+                            objectFit: 'contain',
+                            display: 'block',
+                            opacity: selected ? 1 : 0.6,
+                            filter: selected ? 'brightness(0) invert(1)' : 'none',
+                            pointerEvents: 'none',
+                          }}
+                        />
+                      </button>
+                    );
+                  })}
+                </div>
+                <div
+                  style={{
+                    marginTop: px2vmin(12),
+                    width: '100%',
+                    flex: 1,
+                    minHeight: 0,
+                    overflowY: 'auto',
+                    WebkitOverflowScrolling: 'touch',
+                    display: 'grid',
+                    gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))',
+                    gap: px2vmin(10),
+                    alignContent: 'start',
+                    paddingBottom: px2vmin(10),
+                    boxSizing: 'border-box',
+                  }}
+                >
+                  {filteredFurnitureCatalogItems.map((item) => (
+                    <button
+                      key={item.item_id}
+                      type="button"
+                      onClick={() => handleFurnitureCardClick(item)}
+                      style={{
+                        width: px2vmin(90),
+                        height: px2vmin(112),
+                        borderRadius: px2vmin(16),
+                        backgroundColor: 'rgba(245, 243, 240, 0.1)',
+                        boxSizing: 'border-box',
+                        display: 'flex',
+                        flexDirection: 'column',
+                        alignItems: 'center',
+                        justifyContent: 'center',
+                        rowGap: px2vmin(4),
+                        flexShrink: 0,
+                        border:
+                          item.is_in_use && !item.is_owned
+                            ? '1PX solid rgb(191, 157, 248)'
+                            : '1PX solid rgba(245, 243, 240, 0.28)',
+                        justifySelf: 'center',
+                        padding: 0,
+                        cursor: 'pointer',
+                      }}
+                    >
+                      <img
+                        src={getOssUrl(item.image_name)}
+                        alt={item.name || ''}
+                        draggable={false}
+                        style={{
+                          width: px2vmin(64),
+                          height: px2vmin(64),
+                          objectFit: 'contain',
+                          display: 'block',
+                          pointerEvents: 'none',
+                        }}
+                      />
+                      <div
+                        style={{
+                          position: 'relative',
+                          width: px2vmin(70),
+                          height: px2vmin(20),
+                          flexShrink: 0,
+                          pointerEvents: 'none',
+                        }}
+                      >
+                        <img
+                          src={conversationEnergyStoneCount}
+                          alt=""
+                          draggable={false}
+                          style={{
+                            width: '100%',
+                            height: '100%',
+                            objectFit: 'contain',
+                            display: 'block',
+                          }}
+                        />
+                        <span
+                          style={{
+                            position: 'absolute',
+                            top: '50%',
+                            left: '60%',
+                            transform: 'translate(-50%, -50%)',
+                            fontSize: px2vmin(10),
+                            color: '#000',
+                            fontWeight: 500,
+                          }}
+                        >
+                          {formatCatalogEnergyCount(item)}
+                        </span>
+                      </div>
+                    </button>
+                  ))}
+                </div>
+              </div>
+            ) : (
+              <div
+                style={{
+                  marginTop: px2vmin(12),
+                  marginLeft: 'auto',
+                  marginRight: 'auto',
+                  width: '100%',
+                  display: 'flex',
+                  flexDirection: 'row',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  boxSizing: 'border-box',
+                }}
+              >
+                <img
+                  src={lumiOutfit1ShopImg}
+                  alt=""
+                  draggable={false}
+                  style={{
+                    width: px2vmin(160),
+                    height: px2vmin(160),
+                    objectFit: 'contain',
+                    display: 'block',
+                  }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+        </div>
+      </div>
+
+      {activePurchaseItem ? (
+        <div
+          style={{
+            position: 'fixed',
+            inset: 0,
+            backgroundColor: 'transparent',
+            zIndex: 10031,
+          }}
+          onClick={() => setActivePurchaseItem(null)}
+        >
+          <div
+            onClick={(e) => e.stopPropagation()}
+            style={{
+              position: 'absolute',
+              left: '50%',
+              transform: 'translateX(-50%)',
+              bottom: 0,
+              width: px2vw(393),
+              height: px2vmin(318),
+              backgroundColor: '#34313D',
+              borderTopLeftRadius: px2vmin(32),
+              borderTopRightRadius: px2vmin(32),
+              boxSizing: 'border-box',
+              overflow: 'hidden',
+            }}
+          >
+            <div
+              style={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                right: 0,
+                height: px2vmin(44),
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                pointerEvents: 'none',
+              }}
+            >
+              <div
+                style={{
+                  width: px2vmin(32),
+                  height: px2vmin(4),
+                  backgroundColor: '#79747E',
+                  borderRadius: px2vmin(4),
+                }}
+              />
+            </div>
+
+            <div
+              style={{
+                position: 'absolute',
+                top: px2vmin(56),
+                left: '50%',
+                transform: 'translateX(-50%)',
+                width: px2vmin(80),
+                height: px2vmin(80),
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+              }}
+            >
+              <img
+                src={getOssUrl(activePurchaseItem.image_name)}
+                alt={getCatalogItemDisplayName(activePurchaseItem)}
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  objectFit: 'contain',
+                }}
+              />
+            </div>
+
+            <p
+              style={{
+                position: 'absolute',
+                top: px2vmin(146),
+                left: 0,
+                right: 0,
+                margin: 0,
+                textAlign: 'center',
+                fontSize: px2vmin(22),
+                lineHeight: px2vmin(30),
+                color: '#FFFFFF',
+                fontWeight: 400,
+              }}
+            >
+              {getCatalogItemDisplayName(activePurchaseItem)}
+            </p>
+
+            <p
+              style={{
+                position: 'absolute',
+                top: px2vmin(184),
+                left: '50%',
+                transform: 'translateX(-50%)',
+                width: px2vmin(312),
+                margin: 0,
+                textAlign: 'center',
+                color: 'rgba(255, 255, 255, 0.92)',
+                fontSize: px2vmin(16),
+                lineHeight: px2vmin(24),
+              }}
+            >
+              {formatCatalogEnergyPrice(activePurchaseItem)}
+            </p>
+
+            <div
+              style={{
+                position: 'absolute',
+                top: px2vmin(241),
+                left: '50%',
+                transform: 'translateX(-50%)',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                columnGap: px2vmin(20),
+              }}
+            >
+              <button
+                type="button"
+                onClick={onPurchaseGoChatClick}
+                style={{
+                  width: px2vmin(146),
+                  height: px2vmin(56),
+                  padding: 0,
+                  border: 'none',
+                  borderRadius: px2vmin(12),
+                  background: `url(${primaryDefaultButton}) center/cover no-repeat`,
+                  cursor: 'pointer',
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  color: '#fff',
+                  fontSize: px2vmin(16),
+                  lineHeight: px2vmin(24),
+                  fontWeight: 500,
+                }}
+              >
+                {tProfile('lumiShopPurchaseButton')}
+              </button>
+              <button
+                type="button"
+                onClick={() => setActivePurchaseItem(null)}
+                style={{
+                  width: px2vmin(146),
+                  height: px2vmin(56),
+                  padding: 0,
+                  border: 'none',
+                  borderRadius: px2vmin(12),
+                  background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+                  cursor: 'pointer',
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  color: '#fff',
+                  fontSize: px2vmin(16),
+                  lineHeight: px2vmin(24),
+                  fontWeight: 500,
+                }}
+              >
+                {tProfile('lumiFurnitureCancel')}
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+
+      {activeFurnitureModalItem ? (
+        <div
+          style={{
+            position: 'fixed',
+            inset: 0,
+            backgroundColor: 'transparent',
+            zIndex: 10030,
+          }}
+          onClick={() => setActiveFurnitureModalItem(null)}
+        >
+          <div
+            onClick={(e) => e.stopPropagation()}
+            style={{
+              position: 'absolute',
+              left: '50%',
+              transform: 'translateX(-50%)',
+              bottom: 0,
+              width: px2vw(393),
+              height: px2vmin(318),
+              backgroundColor: '#34313D',
+              borderTopLeftRadius: px2vmin(32),
+              borderTopRightRadius: px2vmin(32),
+              boxSizing: 'border-box',
+              overflow: 'hidden',
+            }}
+          >
+            <div
+              style={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                right: 0,
+                height: px2vmin(44),
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                pointerEvents: 'none',
+              }}
+            >
+              <div
+                style={{
+                  width: px2vmin(32),
+                  height: px2vmin(4),
+                  backgroundColor: '#79747E',
+                  borderRadius: px2vmin(4),
+                }}
+              />
+            </div>
+
+            <div
+              style={{
+                position: 'absolute',
+                top: px2vmin(56),
+                left: '50%',
+                transform: 'translateX(-50%)',
+                width: px2vmin(80),
+                height: px2vmin(80),
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+              }}
+            >
+              <img
+                src={getOssUrl(activeFurnitureModalItem.image_name)}
+                alt={getCatalogItemDisplayName(activeFurnitureModalItem)}
+                draggable={false}
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  objectFit: 'contain',
+                }}
+              />
+            </div>
+
+            <p
+              style={{
+                position: 'absolute',
+                top: px2vmin(146),
+                left: 0,
+                right: 0,
+                margin: 0,
+                textAlign: 'center',
+                fontSize: px2vmin(22),
+                lineHeight: px2vmin(30),
+                color: '#FFFFFF',
+                fontWeight: 400,
+              }}
+            >
+              {getCatalogItemDisplayName(activeFurnitureModalItem)}
+            </p>
+
+            {Number(activeFurnitureModalItem.has_special_scene_trigger || 0) > 0 ? (
+              <p
+                style={{
+                  position: 'absolute',
+                  top: px2vmin(184),
+                  left: '50%',
+                  transform: 'translateX(-50%)',
+                  width: px2vmin(312),
+                  margin: 0,
+                  textAlign: 'center',
+                  color: '#FFFFFF',
+                  fontSize: px2vmin(14),
+                  lineHeight: px2vmin(20),
+                  opacity: 0.9,
+                }}
+              >
+                {tProfile('lumiFurnitureScenarioHint')}
+              </p>
+            ) : null}
+
+            <div
+              style={{
+                position: 'absolute',
+                top: px2vmin(241),
+                left: '50%',
+                transform: 'translateX(-50%)',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                columnGap: px2vmin(20),
+              }}
+            >
+              <button
+                type="button"
+                onClick={() => {
+                  handleFurnitureModalConfirm();
+                }}
+                disabled={furnitureSubmitting}
+                style={{
+                  width: px2vmin(146),
+                  height: px2vmin(56),
+                  padding: 0,
+                  border: 'none',
+                  borderRadius: px2vmin(12),
+                  background: `url(${primaryDefaultButton}) center/cover no-repeat`,
+                  cursor: 'pointer',
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  color: '#fff',
+                  fontSize: px2vmin(16),
+                  lineHeight: px2vmin(24),
+                  fontWeight: 500,
+                  opacity: furnitureSubmitting ? 0.7 : 1,
+                }}
+              >
+                {furnitureModalMode === 'remove'
+                  ? tProfile('lumiFurnitureConfirmRemoval')
+                  : tProfile('lumiFurnitureEquip')}
+              </button>
+              <button
+                type="button"
+                onClick={() => setActiveFurnitureModalItem(null)}
+                disabled={furnitureSubmitting}
+                style={{
+                  width: px2vmin(146),
+                  height: px2vmin(56),
+                  padding: 0,
+                  border: 'none',
+                  borderRadius: px2vmin(12),
+                  background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+                  cursor: 'pointer',
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  color: '#fff',
+                  fontSize: px2vmin(16),
+                  lineHeight: px2vmin(24),
+                  fontWeight: 500,
+                  opacity: furnitureSubmitting ? 0.7 : 1,
+                }}
+              >
+                {tProfile('lumiFurnitureCancel')}
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+    </>
+  );
+};

+ 34 - 48
frontend/src/pages/ProfilePage.tsx

@@ -109,6 +109,7 @@ export const ProfilePage = () => {
   const [username, setUsername] = useState<string>('');
   const [botName, setBotName] = useState<string>('');
   const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
+  const [crystalStoneCount, setCrystalStoneCount] = useState<number>(0);
   const [userCreatedAtIso, setUserCreatedAtIso] = useState<string | null>(null);
   const [lumiHeartCount, setLumiHeartCount] = useState<number>(3);
   const [loading, setLoading] = useState<boolean>(true);
@@ -162,43 +163,6 @@ export const ProfilePage = () => {
   // const sendBtnImage = getOssUrl('send.png'); // Unused - replaced by primary buttons
   // const sendBtnActiveImage = getOssUrl('send-a.png'); // Unused - replaced by primary buttons
 
-  // 获取能量石数量
-  const fetchEnergyStoneCounts = useCallback(
-    async (userId: string) => {
-      try {
-        const apiBaseUrl = getApiBaseUrl();
-        const request = async () =>
-          fetch(`${apiBaseUrl}/api/users/${userId}/energy-stones/count`, {
-            method: 'GET',
-            headers: getApiHeaders(),
-          });
-
-        let response = await request();
-        if (response.status === 401) {
-          // 会话失效:清空本地状态并统一跳转到 /begin
-          console.warn(
-            '[ProfilePage] 请求能量石数量接口返回 401,认为会话失效,准备清空本地并跳转 /begin',
-            {
-              userId,
-            }
-          );
-          clearUserSessionAndRedirectToBegin();
-          return;
-        }
-
-        if (response.ok) {
-          const data = await response.json();
-          if (data.success && data.data) {
-            setConversationStoneCount(data.data.conversation_count || 0);
-          }
-        }
-      } catch (error) {
-        console.error('[ProfilePage] 获取能量石数量失败:', error);
-      }
-    },
-    []
-  );
-
   // 加载用户信息
   useEffect(() => {
     const loadUserData = async () => {
@@ -225,11 +189,10 @@ export const ProfilePage = () => {
             void i18n.changeLanguage(pl);
           }
           setUserCreatedAtIso(userInfo.data.created_at || null);
+          setConversationStoneCount(userInfo.data.conversation_energy_stone_total ?? 0);
+          setCrystalStoneCount(userInfo.data.crystal_stone_total ?? 0);
         }
 
-        // 获取能量石数量
-        await fetchEnergyStoneCounts(userId);
-
         try {
           const intimacyRes = await getIntimacyStatus(userId);
           if (intimacyRes.success && intimacyRes.data) {
@@ -265,7 +228,7 @@ export const ProfilePage = () => {
     loadUserData();
     // 仅挂载时拉取;preferred_language 同步 i18n 不应把 i18n 列入 deps,避免切换语言后重复请求
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [navigate, fetchEnergyStoneCounts]);
+  }, [navigate]);
 
   // 防止弹窗打开时背景滚动
   useEffect(() => {
@@ -439,6 +402,15 @@ export const ProfilePage = () => {
     [handleLumiFurnitureClick],
   );
 
+  const handleLumiShopClick = useCallback(() => {
+    navigate('/lumi-shop', { state: { from: '/profile' } });
+  }, [navigate]);
+
+  const onLumiShopRowClick = useMemo(
+    () => wrapTrackedClick('profile', 'lumi_bag_shop', handleLumiShopClick),
+    [handleLumiShopClick],
+  );
+
   // 打开反馈弹窗
   const handleOpenFeedbackModal = useCallback(() => {
     setShowFeedbackModal(true);
@@ -510,7 +482,7 @@ export const ProfilePage = () => {
     return (
       <div
         style={{
-          width: '100vw',
+          width: '100%',
           height: '100vh',
           backgroundColor: '#1A1821',
           display: 'flex',
@@ -731,7 +703,7 @@ export const ProfilePage = () => {
           onClick={wrapTrackedClick('profile', 'energy_stone_conversation', () => handleEnergyStoneClick('conversation'))}
           style={{
             position: 'relative',
-            width: px2vmin(74),
+            width: px2vmin(80),
             cursor: 'pointer',
             flexShrink: 0,
           }}
@@ -749,7 +721,7 @@ export const ProfilePage = () => {
             style={{
               position: 'absolute',
               top: '50%',
-              left: '60%',
+              left: '66%',
               transform: 'translate(-50%, -50%)',
               fontSize: px2vmin(10),
               color: '#000',
@@ -762,7 +734,7 @@ export const ProfilePage = () => {
         <div
           role="button"
           tabIndex={0}
-          aria-label={`${t('missionStoneTitle')} 0`}
+          aria-label={`${t('missionStoneTitle')} ${crystalStoneCount}`}
           onClick={onMissionEnergyStoneClick}
           onKeyDown={(e) => {
             if (e.key === 'Enter' || e.key === ' ') {
@@ -772,7 +744,7 @@ export const ProfilePage = () => {
           }}
           style={{
             position: 'relative',
-            width: px2vmin(74),
+            width: px2vmin(80),
             flexShrink: 0,
             cursor: 'pointer',
           }}
@@ -791,14 +763,14 @@ export const ProfilePage = () => {
             style={{
               position: 'absolute',
               top: '50%',
-              left: '60%',
+              left: '66%',
               transform: 'translate(-50%, -50%)',
               fontSize: px2vmin(10),
               color: '#000',
               fontWeight: 500,
             }}
           >
-            0
+            {crystalStoneCount}
           </span>
         </div>
       </div>
@@ -1659,12 +1631,24 @@ export const ProfilePage = () => {
                     {t('lumiBagTitle')}
                   </span>
                   <div
+                    role="button"
+                    tabIndex={0}
+                    aria-label={t('lumiBagShop')}
+                    onClick={onLumiShopRowClick}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter' || e.key === ' ') {
+                        e.preventDefault();
+                        onLumiShopRowClick();
+                      }
+                    }}
                     style={{
                       display: 'flex',
                       flexDirection: 'row',
                       alignItems: 'center',
                       gap: px2vmin(4),
                       flexShrink: 0,
+                      cursor: 'pointer',
+                      WebkitTapHighlightColor: 'transparent',
                     }}
                   >
                     <img
@@ -1676,6 +1660,7 @@ export const ProfilePage = () => {
                         height: px2vmin(20),
                         display: 'block',
                         objectFit: 'contain',
+                        pointerEvents: 'none',
                       }}
                     />
                     <span
@@ -1685,6 +1670,7 @@ export const ProfilePage = () => {
                         color: '#fff',
                         fontWeight: 500,
                         whiteSpace: 'nowrap',
+                        pointerEvents: 'none',
                       }}
                     >
                       {t('lumiBagShop')}

+ 5 - 0
frontend/src/router/index.tsx

@@ -19,6 +19,7 @@ import { PhoneLogin } from '@/pages/PhoneLogin';
 import { LoginRegisterPage } from '@/pages/LoginRegisterPage';
 import { ProfilePage } from '@/pages/ProfilePage';
 import { LumiOutfitsPage } from '@/pages/LumiOutfitsPage';
+import { LumiShopPage } from '@/pages/LumiShopPage';
 import { UserGuard } from '@/components/UserGuard';
 import { PageVisitReporter } from '@/components/PageVisitReporter';
 import { RootRedirect } from '@/pages/RootRedirect';
@@ -200,6 +201,10 @@ export const router = createBrowserRouter([
         path: '/lumi-outfits',
         element: <LumiOutfitsPage />,
       },
+      {
+        path: '/lumi-shop',
+        element: <LumiShopPage />,
+      },
       {
         path: '*',
         element: <Navigate to="/" replace />,

+ 30 - 0
frontend/src/utils/api.ts

@@ -342,6 +342,10 @@ export interface GetUserResponse {
     bot_name?: string;
     total_speech_duration_ms?: number;
     user_level?: number;
+    /** 对话能量石余额(users.conversation_energy_stone_total) */
+    conversation_energy_stone_total?: number;
+    /** 水晶石余额(users.crystal_stone_total) */
+    crystal_stone_total?: number;
     current_background_image_id?: string;
     current_background_image_metadata?: Record<string, any>;
     preferred_language?: 'zh' | 'en';
@@ -1230,6 +1234,13 @@ export interface ChatbotOwnedItem {
   is_in_use: boolean;
 }
 
+/** 上架家具/幕布全库(含是否已拥有),用于 Lumi 商店等 */
+export interface ChatbotShopCatalogItem extends ChatbotOwnedItem {
+  is_owned: boolean;
+  /** 能量石售价(来自 shop_items.price_energy) */
+  price_energy?: number;
+}
+
 export interface GetChatbotOwnedItemsResponse {
   success: boolean;
   data: ChatbotOwnedItem[];
@@ -1249,6 +1260,25 @@ export const getChatbotOwnedItems = async (user_id: string): Promise<GetChatbotO
   return response.json();
 };
 
+export interface GetChatbotShopCatalogResponse {
+  success: boolean;
+  data: ChatbotShopCatalogItem[];
+}
+
+export const getChatbotShopCatalog = async (user_id: string): Promise<GetChatbotShopCatalogResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-catalog`, {
+    method: 'GET',
+    headers: getApiHeaders(),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
 export interface UpdateChatbotBackdropResponse {
   success: boolean;
   data: {

+ 1 - 0
frontend/src/utils/tracking.ts

@@ -35,6 +35,7 @@ const PATH_TO_PAGE_ID: Record<string, string> = {
   '/phone-login': 'phone_login',
   '/profile': 'profile',
   '/lumi-outfits': 'lumi_outfits',
+  '/lumi-shop': 'lumi_shop',
   '/error': 'error',
 };
 

+ 8 - 1
plus50EnergyStones/plus50_energy_stones.py

@@ -1,7 +1,8 @@
 #!/usr/bin/env python3
 """
 回馈用户:为指定邮箱的已注册用户增加 50 个对话能量石(赠送)。
-- 使用 user_bonus_energy_stones 表记录赠送数量,与 conversation_energy_stones 分开。
+- 使用 user_bonus_energy_stones 表记录赠送数量,与 conversation_energy_stones 分开;
+  同时增加 users.conversation_energy_stone_total,与线上一致。
 - 根据 users 表的 email 字段匹配,仅对已注册用户增加。
 - 已发放过的用户会记录在 plus50_granted.txt,不重复增加。
 - 每 10 分钟执行一轮,直到清单内所有用户都已完成赠送后脚本自动退出(未注册用户会在后续注册后被下一轮发现并发放)。
@@ -156,9 +157,15 @@ async def run_one_round(db_config: dict) -> bool:
                 VALUES (%s, %s)
                 ON DUPLICATE KEY UPDATE bonus_conversation_count = bonus_conversation_count + %s
             """
+            bump_user_total_sql = """
+                UPDATE users
+                SET conversation_energy_stone_total = conversation_energy_stone_total + %s
+                WHERE user_id = %s
+            """
             async with conn.cursor() as cur:
                 for _orig, user_id in added:
                     await cur.execute(upsert_sql, (user_id, BONUS_COUNT, BONUS_COUNT))
+                    await cur.execute(bump_user_total_sql, (BONUS_COUNT, user_id))
             await conn.commit()
             append_granted_emails([orig for orig, _ in added])
             print(f"[本轮] 已为 {len(added)} 人增加 {BONUS_COUNT} 颗赠送能量石: {[x[0] for x in added]}")

+ 2 - 1
项目文档/chatbot_mysql5_建表.sql

@@ -12,7 +12,8 @@ CREATE TABLE `shop_items` (
   `name_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '商品英文名称',
   `item_type` ENUM('furniture','clothing','backdrop') NOT NULL COMMENT '商品类型',
   `placement_type` ENUM('wall','floor','backdrop','clothing') NOT NULL COMMENT '摆放/使用类型',
-  `image_name` VARCHAR(255) NOT NULL COMMENT '图片名称',
+  `image_name` VARCHAR(255) NOT NULL COMMENT '列表/卡片展示用图片名称(OSS)',
+  `render_image_name` VARCHAR(255) NULL DEFAULT NULL COMMENT '场景网格/幕布渲染用图片(OSS);为空则同 image_name',
 
   `size_rows` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '占用行数(家具用)',
   `size_cols` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '占用列数(家具用)',

Деякі файли не було показано, через те що забагато файлів було змінено