zlwu 8 meses atrás
pai
commit
fcc10ded8f

+ 12 - 5
dailyCount/run_user_daily_activity.py

@@ -28,7 +28,8 @@ def load_config(conf_path: str) -> configparser.ConfigParser:
     if not os.path.isfile(conf_path):
         print(f"配置文件不存在: {conf_path}", file=sys.stderr)
         sys.exit(1)
-    cfg = configparser.ConfigParser()
+    # 关闭默认的 % 插值功能,避免像 "page.%__enter" 这样的值触发插值错误
+    cfg = configparser.ConfigParser(interpolation=None)
     cfg.read(conf_path, encoding="utf-8")
     return cfg
 
@@ -186,7 +187,11 @@ def main() -> None:
     parser = argparse.ArgumentParser(description="用户日活跃聚合 -> user_daily_activity")
     parser.add_argument("--config", default=DEFAULT_CONF, help="setting.conf 路径")
     parser.add_argument("--days", type=int, default=0, help="补跑最近 N 天(含今天),0 表示仅今天")
-    parser.add_argument("--daemon", action="store_true", help="按 interval_minutes 循环执行")
+    parser.add_argument(
+        "--once",
+        action="store_true",
+        help="仅执行一次(默认按 interval_minutes 守护循环执行)",
+    )
     args = parser.parse_args()
 
     cfg = load_config(args.config)
@@ -214,14 +219,16 @@ def main() -> None:
         finally:
             conn.close()
 
-    if args.daemon:
+    if args.once:
+        # 仅执行一次(可结合 --days 使用)
+        do_run()
+    else:
+        # 默认守护模式:根据 interval_minutes 周期性执行
         import time
         print(f"[user_daily_activity] 守护模式,每 {interval_minutes} 分钟执行一次")
         while True:
             do_run()
             time.sleep(interval_minutes * 60)
-    else:
-        do_run()
 
 
 if __name__ == "__main__":

+ 20 - 1
frontend/src/components/ChatBotStaticShell.tsx

@@ -5,6 +5,7 @@ import { getUserId, getStorage, STORAGE_KEYS } from '@/utils/storage';
 import { getIntimacyStatus, getAvatarGifs, type AvatarGifSet } from '@/utils/api';
 import { getOssUrl } from '@/utils/oss';
 import { px2vmin, px2vw } from '@/utils/viewport';
+import { STOOL_LAYOUT } from '@/constants/chatbot';
 import { wrapTrackedClick } from '@/utils/tracking';
 import './MissionEnergyStoneModal.css';
 
@@ -30,7 +31,8 @@ const diaryMenuIcon = getOssUrl('diary-icon.png');
 const luyinDefault = getOssUrl('luyin-default.png');
 const ccCloseIcon = getOssUrl('cc-close-icon.png');
 const ccOpenIcon = getOssUrl('cc-open-icon.png');
-const treesImage = getOssUrl('init2.svg');
+const treesImage = getOssUrl('init3.svg');
+const stoolImage = getOssUrl('stool.png');
 const t1OverlayImage = getOssUrl('t1-overlay.png');
 const tutorialStep1AvatarGif = getOssUrl('03-Cheer.gif');
 const primaryButtonImage = getOssUrl('primary-default.png');
@@ -339,6 +341,23 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
           }}
         />
       )}
+      {/* 背景上方凳子图,与 ChatBotPage 共用 STOOL_LAYOUT */}
+      {stoolImage && (
+        <img
+          src={stoolImage}
+          alt=""
+          className="absolute pointer-events-none"
+          style={{
+            width: px2vmin(STOOL_LAYOUT.width),
+            height: px2vmin(STOOL_LAYOUT.height),
+            left: px2vmin(STOOL_LAYOUT.left),
+            top: px2vmin(STOOL_LAYOUT.top),
+            zIndex: 2,
+            objectFit: 'contain',
+          }}
+          draggable={false}
+        />
+      )}
       {/* 教学步骤 2:左侧介绍框位置改为点击图片 */}
       {step === 2 && (
         <button

+ 129 - 69
frontend/src/pages/ChatBotPage.tsx

@@ -48,6 +48,7 @@ import type { Message, EnergyStoneSpawnMessage, GreetingContextMessage } from '@
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
 import { wrapTrackedClick, trackButtonClick } from '@/utils/tracking';
+import { STOOL_LAYOUT } from '@/constants/chatbot';
 
 const tapToChat = getOssUrl('TapToChat.svg');
 const cancelIcon = getOssUrl('cancel-icon.png');
@@ -65,6 +66,12 @@ const chatBgTop = getOssUrl('chat-bg-top.svg');
 const chatBgRight = getOssUrl('chat-bg-right.svg');
 const chatBgBottom = getOssUrl('chat-bg-bottom.svg');
 const chatBgLeft = getOssUrl('chat-bg-left.svg');
+// 特殊场景(窗前看风景/桌前看书/桌前吃饭)凳子上展示的动画形象
+const SPECIAL_SCENE_GIF: Record<'window' | 'reading' | 'eating', string> = {
+  window: getOssUrl('lumi-windows.gif'),
+  reading: getOssUrl('lumi-book.gif'),
+  eating: getOssUrl('lumi-eat.gif'),
+};
 const settingIcon = getOssUrl('setting-icon.png');
 const funcLockIcon = getOssUrl('func-Lock.png');
 const profileMenuIcon = getOssUrl('profile-icon.png');
@@ -206,6 +213,8 @@ export const ChatBotPage = () => {
   // 情景对话:是否已经在当前场景当前话题下真正开始对话(首条语音已发送)
   const [scenarioConversationStarted, setScenarioConversationStarted] = useState(false);
   const hasSentScenarioContextRef = useRef(false);
+  // 特殊场景:用户点击录音后收起凳子上形象、恢复中间动画形象
+  const [specialSceneAvatarDismissed, setSpecialSceneAvatarDismissed] = useState(false);
   const greetingContextRef = useRef(greetingContext);
   useEffect(() => {
     greetingContextRef.current = greetingContext;
@@ -2269,6 +2278,7 @@ export const ChatBotPage = () => {
           setCurrentTopicIndex(idx >= 0 ? idx : 0);
           hasSentScenarioContextRef.current = false;
           setScenarioConversationStarted(false);
+          setSpecialSceneAvatarDismissed(false); // 进入特殊场景时展示凳子上形象
           if (ctx.firstLine) setRecognizedText(ctx.firstLine);
         } else {
           setGreetingContext(null);
@@ -2586,6 +2596,11 @@ export const ChatBotPage = () => {
     
     // 如果正在连接中或已就绪,允许开始录音(消息会被缓冲)
 
+    // 特殊场景:点击录音后收起凳子上的场景形象,恢复中间动画形象
+    if (greetingContext?.scene && greetingContext.scene !== 'default') {
+      setSpecialSceneAvatarDismissed(true);
+    }
+
     // 一旦用户主动开始录音,则以用户为准:后续所有首句 greeting 消息全部丢弃
     greetingSuppressedRef.current = true;
     // 如果此时已经拿到首句的 requestId,则主动向后端发送 cancel,节省资源
@@ -2885,7 +2900,7 @@ export const ChatBotPage = () => {
 
   // 根据背景图片获取对应的树木装饰图片URL(/chatbot 界面统一使用默认图片,不随用户选择的新年等背景切换)
   const getTreesImage = (): string | null => {
-    return getOssUrl('init2.svg');
+    return getOssUrl('init3.svg');
   };
 
   // 根据背景图片ID获取树木装饰图片的样式配置(/chatbot 使用默认背景,透明度固定为 1)
@@ -2929,6 +2944,48 @@ export const ChatBotPage = () => {
         />
       )}
 
+      {/* 凳子 + 特殊场景 GIF 均基于 STOOL_LAYOUT,改凳子位置时两处同步 */}
+      <img
+        src={getOssUrl('stool.png')}
+        alt=""
+        className="absolute pointer-events-none"
+        style={{
+          width: px2vmin(STOOL_LAYOUT.width),
+          height: px2vmin(STOOL_LAYOUT.height),
+          left: px2vmin(STOOL_LAYOUT.left),
+          top: px2vmin(STOOL_LAYOUT.top),
+          zIndex: 2,
+          objectFit: 'contain',
+        }}
+        draggable={false}
+      />
+
+      {/* 特殊场景:凳子上展示的场景动画形象,位置基于凳子(居中于凳子、底边对齐凳子底) */}
+      {greetingContext?.scene &&
+        greetingContext.scene !== 'default' &&
+        !specialSceneAvatarDismissed &&
+        SPECIAL_SCENE_GIF[greetingContext.scene] && (
+          <img
+            src={SPECIAL_SCENE_GIF[greetingContext.scene]}
+            alt=""
+            className="absolute pointer-events-none"
+            style={{
+              width: px2vmin(STOOL_LAYOUT.sceneGifSize),
+              height: px2vmin(STOOL_LAYOUT.sceneGifSize),
+              left: px2vmin(STOOL_LAYOUT.left + STOOL_LAYOUT.width / 2 - STOOL_LAYOUT.sceneGifSize / 2),
+              top: px2vmin(
+                STOOL_LAYOUT.top +
+                  STOOL_LAYOUT.height -
+                  STOOL_LAYOUT.sceneGifSize +
+                  (STOOL_LAYOUT.sceneGifOffsetYByScene[greetingContext.scene] ?? STOOL_LAYOUT.sceneGifOffsetY)
+              ),
+              zIndex: 2,
+              objectFit: 'contain',
+            }}
+            draggable={false}
+          />
+        )}
+
       {/* 说话时的阴影蒙层:仅录音中出现,发送/取消后立即消失 */}
       {currentState === 'recording' && (
         <div
@@ -3248,56 +3305,59 @@ export const ChatBotPage = () => {
             );
       })}
 
-      {/* AnimaZone 动画形象 - 双 img 固定角色:default 层永远 default;对话层 emotion→talking,放下时静默换 default */}
-      <div
-        className="chatbot-anima-zone-avatar absolute"
-        style={{
-          width: px2vmin(238),
-          height: px2vmin(238),
-          top: px2vmin(292),
-          left: '50%',
-          transform: 'translateX(-50%)',
-          opacity: 1,
-          zIndex: 2,
-        }}
-      >
-        <img
-          src={defaultGifUrl || undefined}
-          alt={currentAvatarAlt}
-          loading="eager"
-          decoding="async"
-          style={{
-            position: 'absolute',
-            inset: 0,
-            width: '100%',
-            height: '100%',
-            objectFit: 'contain',
-            pointerEvents: 'none',
-            zIndex: showConversationOnTop ? 0 : 1,
-            opacity: showConversationOnTop ? 0 : 1,
-          }}
-        />
-        <img
-          src={conversationLayerSrc || undefined}
-          alt={currentAvatarAlt}
-          loading="eager"
-          decoding="async"
+      {/* AnimaZone 动画形象 - 双 img 固定角色:default 层永远 default;对话层 emotion→talking,放下时静默换 default;
+          特殊场景下未点击录音时隐藏,点击录音后显示 */}
+      {!(greetingContext?.scene && greetingContext.scene !== 'default' && !specialSceneAvatarDismissed) && (
+        <div
+          className="chatbot-anima-zone-avatar absolute"
           style={{
-            position: 'absolute',
-            inset: 0,
-            width: '100%',
-            height: '100%',
-            objectFit: 'contain',
-            pointerEvents: 'none',
-            zIndex: showConversationOnTop ? 1 : 0,
-            opacity: showConversationOnTop ? 1 : 0,
+            width: px2vmin(238),
+            height: px2vmin(238),
+            top: px2vmin(292),
+            left: '50%',
+            transform: 'translateX(-50%)',
+            opacity: 1,
+            zIndex: 2,
           }}
-        />
-      </div>
+        >
+          <img
+            src={defaultGifUrl || undefined}
+            alt={currentAvatarAlt}
+            loading="eager"
+            decoding="async"
+            style={{
+              position: 'absolute',
+              inset: 0,
+              width: '100%',
+              height: '100%',
+              objectFit: 'contain',
+              pointerEvents: 'none',
+              zIndex: showConversationOnTop ? 0 : 1,
+              opacity: showConversationOnTop ? 0 : 1,
+            }}
+          />
+          <img
+            src={conversationLayerSrc || undefined}
+            alt={currentAvatarAlt}
+            loading="eager"
+            decoding="async"
+            style={{
+              position: 'absolute',
+              inset: 0,
+              width: '100%',
+              height: '100%',
+              objectFit: 'contain',
+              pointerEvents: 'none',
+              zIndex: showConversationOnTop ? 1 : 0,
+              opacity: showConversationOnTop ? 1 : 0,
+            }}
+          />
+        </div>
+      )}
 
       {/* 情景对话:字幕区域上方话题名称 + 切换话题按钮(仅特殊场景展示),与字幕左对齐;
-          用户在该场景该话题下开始对话后仅隐藏“切换话题按钮”,话题标签仍保留 */}
-      {greetingContext && greetingContext.scene !== 'default' && greetingContext.topics.length > 0 && (() => {
+          用户在该场景该话题下开始对话后整行隐藏(话题标签与切换按钮都不再显示) */}
+      {greetingContext && greetingContext.scene !== 'default' && greetingContext.topics.length > 0 && !scenarioConversationStarted && (() => {
         const currentTopic = greetingContext.topics[currentTopicIndex];
         const topicLabel = currentTopic
           ? (isZh ? (currentTopic.labelZh ?? currentTopic.labelEn ?? currentTopic.key) : (currentTopic.labelEn ?? currentTopic.labelZh ?? currentTopic.key))
@@ -3330,7 +3390,6 @@ export const ChatBotPage = () => {
             >
               {topicLabel}
             </div>
-            {!scenarioConversationStarted && (
             <button
               type="button"
               aria-label="Switch topic"
@@ -3366,7 +3425,6 @@ export const ChatBotPage = () => {
                 draggable={false}
               />
             </button>
-            )}
         </div>
         );
       })()}
@@ -3401,26 +3459,28 @@ export const ChatBotPage = () => {
         </div>
       )}
 
-      {/* Yp 图片 - 设计稿 393×852,相对动画形象顶部 191,64×14 */}
-      <div
-        className="absolute"
-        style={{
-          top: `calc(${px2vmin(292)} + ${px2vmin(191)})`,
-          left: '50%',
-          transform: 'translateX(-50%)',
-          width: px2vmin(64),
-          height: px2vmin(14),
-          zIndex: 10,
-        }}
-      >
-        <img
-          src={YpImage}
-          alt={tCommon('a11y.yp')}
-          className="w-full h-full object-contain"
-          style={{ width: '100%', height: '100%' }}
-          draggable={false}
-        />
-      </div>
+      {/* Yp 图片 - 设计稿 393×852,相对动画形象顶部 191,64×14;特殊场景下点击录音后再显示 */}
+      {!(greetingContext?.scene && greetingContext.scene !== 'default' && !specialSceneAvatarDismissed) && (
+        <div
+          className="absolute"
+          style={{
+            top: `calc(${px2vmin(292)} + ${px2vmin(191)})`,
+            left: '50%',
+            transform: 'translateX(-50%)',
+            width: px2vmin(64),
+            height: px2vmin(14),
+            zIndex: 10,
+          }}
+        >
+          <img
+            src={YpImage}
+            alt={tCommon('a11y.yp')}
+            className="w-full h-full object-contain"
+            style={{ width: '100%', height: '100%' }}
+            draggable={false}
+          />
+        </div>
+      )}
 
       {/* 录音时按钮区域上方:0~50 秒显示 audio-wave.gif,50~60 秒显示 00:50…01:00 后自动发送 */}
       {currentState === 'recording' && (