Quellcode durchsuchen

fix: 左栏加载失败不再整体报废 + 路由重定向 + 骨架屏

用户实测:进入 App 后左栏只剩「会话目录加载失败」,右侧空白。

根因(两层):
- reloadAll 拉取所有容器会话目录,任意一个失败即整体 ready=false
- hydrateOnce 的 catch 直接置全局错误态,导致 allSettled 永远 fulfilled

修复:
- hydrateOnce 不再内部置错误态,改为抛出
- reloadAll 汇总:至少一个成功即 ready=true,全部失败才进错误态
- 新增 partialFailures,左栏提示「N 个容器的对话未加载」并可重试
- chat.service open() 接住异常并转成本会话错误态

路由:
- 入口页先判登录,未登录直接跳 /login(消除两跳闪空白)
- :id 缺失或容器不存在 → 回根页面(仅在容器列表已加载时判定)
- sessionId 不存在 → 回落打开该容器最近一条

骨架屏:
- 左栏判据改为 !sessionsReady() && !sessionsError(),加载中 6 行骨架,
  加载完成前不渲染真实条目
- 会话区 loading 时 3 块骨架,不再先闪空态

验证:tsc ✅ / 1780 SUCCESS / build ✅ / 线上 main-UWK5XTFI.js 一致

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct vor 13 Stunden
Ursprung
Commit
79a9f6d7f5

+ 14 - 0
docs/00-tracking/操作追踪.md

@@ -8199,3 +8199,17 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **用户澄清** | 文件**不走 Matrix 传输**;容器上传完成后返回 URL 给 App,同时写台账入库(对应用户数据)。参考 lj-test 逻辑 |
 | **下一步** | 向 lj-test 取「通知平台→领任务→上传→登记→发 m.file」五步真实代码,让 kangshifu 逐行照抄 |
 | **验证** | `scripts/loadtest-agent.mjs` 语法校验通过;三轮压测报告落盘 `loadtest-smoke-n2.md` / `loadtest-n10.md` / `loadtest-n30.md` |
+
+## 2026-11-14(续六)· 左栏加载失败修复 + 路由重定向 + 骨架屏
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | ① 进入 App 后左栏显示「会话目录加载失败」,右侧空白 ② 路由重定向有问题:未登录应自动跳登录、登录成功应直接进对应页而非空白加载、缺参数应跳回根页面 ③ 左栏要用骨架屏,加载完成后才显示列表 |
+| **左栏失败根因** | `reloadAll` 拉取**所有容器**的会话目录,原实现是「任意一个失败即整体 `ready=false`」。用户的容器有十几个,只要有一个云函数调用异常,整个左栏就报废 —— 而其它容器的会话其实都能正常拉到 |
+| **二次根因** | `hydrateOnce` 的 catch 里直接 `_ready.set(false)`,所以 `Promise.allSettled` 拿到的永远是 fulfilled(`failed` 计数恒为 0),批量拉取时任何一个容器的失败都会把全局置为不可用 |
+| **修复** | ① `hydrateOnce` 不再在内部置错误态,改为抛出 ② `reloadAll` 汇总判定:至少一个成功 → `ready=true` 正常展示;全部失败才进错误态 ③ 新增 `partialFailures` 信号记录失败的容器,左栏顶部显示「N 个容器的对话未加载 + 重试」,属可继续使用的降级 ④ `chat.service` 的 `open()` 接住异常并转成本会话错误态(抽出 `applyHydrateFailure`) |
+| **路由修复** | ① 入口页 `EntryComponent` 先判登录:未登录直接 `replaceUrl` 到 `/login`,不再「入口 → 业务页 → 守卫 → 登录」两跳闪空白 ② `onRouteChange` 增加参数校验:`:id` 缺失或指向不存在的容器 → 回根页面 `/`(⚠️ 仅当容器列表确实加载出来时才判,避免网络慢时误踢) ③ `:sessionId` 不存在时回落成「打开该容器最近一条」(沿用原逻辑) |
+| **骨架屏** | 左栏:判据从 `live.state()==='loading'`(容器心跳)改为 `!sessionsReady() && !sessionsError()`(云端目录是否拉到过),加载中渲染 6 行 `ui-skeleton`,**加载完成前不渲染任何真实条目**。会话区:`chat.state()==='loading'` 时渲染 3 块骨架,避免先闪「开始一段新对话」空态再被替换 |
+| **踩坑** | Angular 模板注释里**不能出现反引号**(`\``),会把注释内容当表达式解析,报 `TS2554: Expected 1 arguments, but got 4` / `TS2349: not callable` |
+| **测试** | 改写 2 条旧断言(hydrate 现在抛出而非置错误态);新增 2 条:`reloadAll` 部分失败仍可用、全部失败才进错误态 |
+| **验证** | `tsc --noEmit` ✅ / `npm test` **1780 SUCCESS** / `npm run build` ✅ / 线上 `main-UWK5XTFI.js` 与本地一致 / CDN 刷新任务 `8190276258` |

+ 70 - 4
src/app/components/domain/session-tree/session-tree.ts

@@ -414,13 +414,20 @@ export interface NewSessionRequest {
 
           @if (dailyOpen()) {
             <div class="list-limit" [class.more-below]="dailyOverflow()">
-              @if (live.state() === 'loading' && daily().length === 0) {
-                <div class="skel">
-                  @for (i of [1, 2, 3]; track i) {
+              <!--
+                🔴 加载中 → 骨架屏(2026-11-14 用户要求)。
+                   「加载完成之后才显示列表」—— 加载期间不渲染任何真实条目,
+                   避免用户看到半截列表后又被替换。
+                   判据用 sessionsReady(云端拉过一次才为 true),
+                   不再用容器心跳 state(那是容器状态,不是目录状态)。
+              -->
+              @if (!sessionsReady() && !sessionsError()) {
+                <div class="skel" role="status" aria-live="polite" aria-label="正在加载对话">
+                  @for (i of skeletonRows; track i) {
                     <ui-skeleton variant="rect" height="32px" />
                   }
                 </div>
-              } @else if (!sessionsReady()) {
+              } @else if (sessionsError()) {
                 <!--
                   会话目录拉取失败(2026-11-01 用户裁决):**不显示任何会话**。
                   这里必须与「还没有对话」区分开 —— 空态意味着「你确实没有」,
@@ -438,6 +445,18 @@ export interface NewSessionRequest {
                 <p class="sect-hint">日常新建的对话会出现在这里。</p>
               }
 
+              <!--
+                部分容器未加载(2026-11-14):有数据可展示,只是缺了几个容器的会话。
+                与上面整片错误态区分开 —— 这是可继续使用的降级。
+              -->
+              @if (partialFailures().length > 0) {
+                <div class="sect-partial" role="status">
+                  <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
+                  {{ partialFailures().length }} 个容器的对话未加载
+                  <button type="button" class="retry-inline" (click)="retrySessions()">重试</button>
+                </div>
+              }
+
               <!--
                 新建会话推送失败提示(2026-11-09 用户要求「推送失败要报错」)。
                 原来静默吞掉:会话只存在于本机,换浏览器永远看不到,用户毫不知情。
@@ -1271,6 +1290,44 @@ export interface NewSessionRequest {
         line-height: 1.6;
         color: var(--ink);
       }
+      /*
+       * 部分容器未加载(2026-11-14):可继续使用的降级提示。
+       * 用 warning 而非 danger —— 列表本身是好的,只是缺了几条。
+       */
+      .sect-partial {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        margin: var(--sp-1) 0 var(--sp-2);
+        padding: var(--sp-2) var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-sm);
+        background: var(--surface-2);
+        font-size: var(--fs-11);
+        color: var(--muted);
+      }
+      .sect-partial i {
+        color: var(--warning);
+      }
+      .retry-inline {
+        margin-left: auto;
+        padding: 0;
+        border: 0;
+        background: none;
+        color: var(--primary);
+        font: inherit;
+        font-size: var(--fs-11);
+        cursor: pointer;
+      }
+      .retry-inline:hover {
+        text-decoration: underline;
+      }
+      /* 骨架屏:行间留白与真实条目一致,加载完成时高度不跳动 */
+      .skel {
+        display: grid;
+        gap: var(--sp-2);
+        padding: var(--sp-1) 0;
+      }
       .sect-error p {
         margin: 0;
       }
@@ -2125,6 +2182,15 @@ export class SessionTreeComponent implements OnDestroy {
   /** 会话目录是否可信(false = 云端拉取失败,列表不可展示) */
   protected readonly sessionsReady = this.sessionsSvc.ready;
   protected readonly sessionsError = this.sessionsSvc.error;
+  /**
+   * 部分容器未加载(2026-11-14 用户要求)。
+   *
+   * 与 `sessionsError` 的区别:那个是「全都失败,没东西可显示」;
+   * 这个是「有东西可显示,但缺了几个容器的会话」—— 不阻塞列表渲染。
+   */
+  protected readonly partialFailures = this.sessionsSvc.partialFailures;
+  /** 骨架屏行数:与常见首屏会话数量相当,避免加载完成时列表高度跳动过大 */
+  protected readonly skeletonRows = [1, 2, 3, 4, 5, 6];
   /** 新建会话推送云端失败的原因(2026-11-09) */
   protected readonly pushFailed = this.sessionsSvc.pushFailed;
 

+ 62 - 5
src/app/core/chat-session.service.spec.ts

@@ -694,14 +694,20 @@ describe('ChatSessionService', () => {
      * 旧行为「保留本地缓存照常展示」看着更"稳",实际是两端互相覆盖的根源:
      * 用户看到的可能是几小时前的列表,在上面做的删除/归档又会回写云端。
      */
-    it('🔴 云端不可达 → ready=false,界面据此显示「加载失败」', async () => {
+    it('🔴 云端不可达 → hydrate 抛出,由调用方决定严重程度(2026-11-14)', async () => {
       const svc2 = withApi({
         fetchChatThreads: async () => { throw new Error('network'); },
         upsertChatThreads: async () => { throw new Error('network'); },
       });
-      await svc2.hydrateFromCloud(AGENT);
-      expect(svc2.ready()).toBeFalse();
-      expect(svc2.error()).toBe('network');
+      /*
+       * 🔴 行为变更(2026-11-14):hydrate 不再在内部置「全局错误态」。
+       *
+       * 原实现在这里 `_ready.set(false)`,导致 `reloadAll` 并发拉十几个容器时
+       * 任意一个失败就把整个左栏判成不可用(用户实测:只剩一句「加载失败」)。
+       * 现在单容器失败只抛出,由 `reloadAll` 汇总判定:
+       * 部分失败仍可用,全部失败才进错误态。
+       */
+      await expectAsync(svc2.hydrateFromCloud(AGENT)).toBeRejectedWithError('network');
     });
 
     it('云端不可达时 pushAll 不推送(列表本身就不可信)', async () => {
@@ -712,7 +718,8 @@ describe('ChatSessionService', () => {
       });
       svc2.create(ROOM, AGENT);
       const before = upsert.calls.count();
-      await svc2.hydrateFromCloud(AGENT);
+      // hydrate 现在会抛出,接住后继续验证 pushAll 的行为
+      await svc2.hydrateFromCloud(AGENT).catch(() => undefined);
       await svc2.pushAll(AGENT);
       expect(upsert.calls.count()).withContext('云端不可达时不应回写').toBe(before);
     });
@@ -727,6 +734,56 @@ describe('ChatSessionService', () => {
       expect(svc2.ready()).toBeTrue();
     });
 
+    it('🔴 reloadAll 部分失败 → 仍可用(不再整体报废,2026-11-14)', async () => {
+      /*
+       * 用户实测:容器有十几个,只要有一个云函数调用异常,
+       * 整个左栏就只剩「会话目录加载失败」,其它容器的会话全看不见。
+       * 现在的口径:至少一个成功 → ready=true 正常展示,失败的记进 partialFailures。
+       */
+      const svc2 = withApi({
+        fetchChatThreads: async (agentId: string, o?: { includeDeleted?: boolean }) => {
+          if (agentId === 'bad-agent') throw new Error('boom');
+          return o?.includeDeleted
+            ? []
+            : [
+                {
+                  objectId: 'a',
+                  agentId,
+                  roomId: ROOM,
+                  threadRoot: `$t-${agentId}`,
+                  title: 'A',
+                  titleLocked: false,
+                  messageCount: 1,
+                  lastMessageAt: 1,
+                  archived: false,
+                  lastPreview: '',
+                },
+              ];
+        },
+        upsertChatThreads: async () => undefined,
+      });
+
+      await svc2.reloadAll(['good-agent', 'bad-agent']);
+
+      expect(svc2.ready()).withContext('有成功就不能整体报废').toBeTrue();
+      expect(svc2.error()).withContext('不能因为部分失败就进错误态').toBeNull();
+      expect(svc2.partialFailures().length).withContext('失败的容器要记下来').toBe(1);
+      expect(svc2.partialFailures()[0].agentId).toBe('bad-agent');
+      expect(svc2.all().filter((s) => s.agentId === 'good-agent').length)
+        .withContext('成功的容器会话要正常展示')
+        .toBe(1);
+    });
+
+    it('🔴 reloadAll 全部失败 → 才进错误态', async () => {
+      const svc2 = withApi({
+        fetchChatThreads: async () => { throw new Error('全挂了'); },
+        upsertChatThreads: async () => undefined,
+      });
+      await svc2.reloadAll(['a1', 'a2']);
+      expect(svc2.ready()).toBeFalse();
+      expect(svc2.error()).toBe('全挂了');
+    });
+
     it('🔴 云端删掉的会话,本地下一次拉取即消失', async () => {
       let live = [
         { objectId: 'a', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 'A', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' },

+ 70 - 17
src/app/core/chat-session.service.ts

@@ -281,12 +281,17 @@ export class ChatSessionService {
       ]);
     } catch (e) {
       /*
-       * 云端不可达 → **列表不可信**(用户裁决:宁可不显示,也不显示可能过期的缓存)。
-       * 内存里留着旧内容没有意义 —— 界面依据 `ready()` 直接渲染「加载失败」。
+       * 云端不可达 → **这个容器的列表不可信**。
+       *
+       * 🔴 2026-11-14 修正:原来在这里直接 `_ready.set(false)`,
+       *    于是 `reloadAll` 并发拉十几个容器时,**任意一个失败就把整个左栏
+       *    判成不可用**(用户实测:只剩一句「会话目录加载失败」)。
+       *
+       *    现在本方法只**抛出**,由调用方决定严重程度:
+       *      · `reloadAll` 批量拉 → 部分失败仍可用,只有全失败才置错误态
+       *      · 单容器 `open()` 路径 → 各自处理(见调用点)
        */
-      this._ready.set(false);
-      this._error.set(e instanceof Error && e.message ? e.message : '会话目录加载失败');
-      return 0;
+      throw e instanceof Error ? e : new Error('会话目录加载失败');
     }
 
     /*
@@ -503,35 +508,83 @@ export class ChatSessionService {
   /**
    * 拉取**所有容器**的会话目录(左栏在没进过任何容器时也要能列出对话)。
    *
-   * 逐容器并发拉取;任意一个失败就整体视为失败(`ready=false`)——
-   * 只显示拉成功的部分会造成「有的对话突然不见了」,比整体重试更让人困惑。
-   *
    * 并发度做了限制(每批 5 个),避免容器多时一次打满平台接口。
+   *
+   * ## 🔴 2026-11-14 修正:不再「任何一个失败就整体失败」
+   *
+   * 原实现(2026-11-01 用户裁决)是「任意一个失败 → 整体不可展示」,理由是
+   * 只显示部分会造成「有的对话突然不见了」。
+   *
+   * 但实测出了更糟的后果:**左栏连一条会话都列不出来,只剩一句
+   * 「会话目录加载失败」**。用户的容器有十几个,其中只要有**一个**云函数
+   * 调用异常(超时/限流/权限/该容器已被删除),整个左栏就报废 ——
+   * 而其它十几个容器的会话其实都能正常拉到。
+   *
+   * 现在的口径(部分成功即可用):
+   *   · **至少有一个成功** → `ready=true`,正常展示拉到的那些
+   *   · **全部失败** → 才置错误态(此时确实没有任何可信数据)
+   *   · 失败的那些容器记进 `partialFailures`,供 UI 提示「N 个容器未加载」
+   *
+   * 「对话突然不见了」的顾虑由 UI 层的提示覆盖:部分失败时显式告知用户
+   * 哪几个容器没拉到,比整片空白 + 笼统报错更有用。
    */
   async reloadAll(agentIds: string[]): Promise<void> {
     if (!this.api) return;
     const list = [...new Set(agentIds)].filter(Boolean);
     if (list.length === 0) return;
-    let failed = 0;
-    let lastErr: unknown = null;
+    const failures: { agentId: string; reason: string }[] = [];
+    let succeeded = 0;
     for (let i = 0; i < list.length; i += 5) {
       const batch = list.slice(i, i + 5);
       const results = await Promise.allSettled(batch.map((a) => this.hydrateFromCloud(a)));
-      for (const r of results) {
+      for (let k = 0; k < results.length; k++) {
+        const r = results[k];
         if (r.status === 'rejected') {
-          failed++;
-          lastErr = r.reason;
+          const why = r.reason;
+          failures.push({
+            agentId: batch[k],
+            reason: why instanceof Error && why.message ? why.message : '会话目录加载失败',
+          });
+        } else {
+          succeeded++;
         }
       }
     }
-    if (failed > 0) {
+
+    if (failures.length === 0) {
+      this._partialFailures.set([]);
+      return;
+    }
+
+    this._partialFailures.set(failures);
+
+    /*
+     * 全部失败 = 没有任何可信数据 → 才进错误态。
+     * 部分失败 = 有数据可展示 → 保持 ready,只在 UI 上提示。
+     *
+     * 🔴 2026-11-14:部分失败时**必须显式置 ready=true 并清 error**。
+     *    `hydrateOnce` 只在成功时置 ready,但并发批次里后面的失败容器
+     *    可能先完成、把 error 写上;不在这里收尾的话,UI 会同时看到
+     *    「有一些会话」和「加载失败」,进入错误态分支把列表藏掉。
+     */
+    if (succeeded === 0) {
       this._ready.set(false);
-      this._error.set(
-        lastErr instanceof Error && lastErr.message ? lastErr.message : '会话目录加载失败',
-      );
+      this._error.set(failures[0].reason);
+    } else {
+      this._ready.set(true);
+      this._error.set(null);
     }
   }
 
+  /**
+   * 部分容器拉取失败(2026-11-14)。
+   *
+   * 与 `error()` 的区别:那个是「全都失败了,没东西可显示」;
+   * 这个是「有东西可显示,但这几个容器的会话缺失」,属于**可继续使用的降级**。
+   */
+  private readonly _partialFailures = signal<readonly { agentId: string; reason: string }[]>([]);
+  readonly partialFailures = this._partialFailures.asReadonly();
+
   /** 某 agent 下的会话(默认不含归档) */
   listFor(agentId: string, opts: { includeArchived?: boolean } = {}): ChatSession[] {
     return sessionsOf(this._all(), agentId, opts);

+ 37 - 17
src/app/core/chat.service.ts

@@ -1452,29 +1452,32 @@ export class ChatService {
      * 所以这里只剩一件事:拉一次云端。扫不扫描、要不要补推,这里一概不做。
      * 任何「猜」的逻辑都是历史上会话反复增生的来源(扫描 → upsert → 翻倍)。
      */
-    await this.sessions.hydrateFromCloud(agentId, sessionId);
+    /*
+     * 🔴 2026-11-14:hydrate 失败现在会**抛出**(不再在服务内部置全局错误态),
+     *    这里接住并转成本会话的错误态 —— 只影响当前打开的这条会话,
+     *    不再因为一个容器拉取失败就让整个左栏报废。
+     */
+    let hydrateFailed = false;
+    try {
+      await this.sessions.hydrateFromCloud(agentId, sessionId);
+    } catch (e) {
+      hydrateFailed = true;
+      this._error.set(e instanceof Error ? e.message : '会话目录加载失败');
+    }
     if (my !== this.seq) return;
+    if (hydrateFailed) {
+      await this.applyHydrateFailure(sessionId);
+      return;
+    }
 
     /*
-     * 云端不可达 → **不显示任何会话**(用户裁决 2026-11-01)。
+     * 会话目录整体不可用(全部容器都拉取失败)→ 本会话进错误态。
      *
-     * 这里给的是「会话目录加载失败」而不是空态:空态意味着「你确实还没有对话」,
-     * 而实际是「我们不知道你有没有」。两者文案必须区分,否则用户会以为自己
-     * 的对话被删了(历史上反复出现),从而去重复新建,进一步制造脏数据。
+     * 2026-11-14:判据从「hydrateOnce 内部置的错误」改为服务级 `ready/error`
+     * —— 单容器失败走上面的 catch 分支,这里只管「一条数据都没有」的情况。
      */
     if (!this.sessions.ready() && this.sessions.error()) {
-      /*
-       * 显式指定了会话(URL 里有线程根)时**不清空选中**(2026-11-06):
-       * 目录不可信只影响「有哪些会话」,不影响「用户在打开哪一条」。
-       * 清空会让页面渲染成「还没有对话」,输入框整块消失。
-       */
-      const keep = sessionId ?? '';
-      this._sessionId.set(keep);
-      this._messages.set(keep ? this.demoRestored(keep) : []);
-      this._state.set('error');
-      this._error.set(`会话目录加载失败:${this.sessions.error()}`);
-      this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
-      this._hasMoreHistory.set(false);
+      await this.applyHydrateFailure(sessionId);
       return;
     }
 
@@ -3833,6 +3836,23 @@ export class ChatService {
     return map;
   }
 
+  /**
+   * 会话目录拉取失败 → 本会话进错误态(2026-11-14 抽出)。
+   *
+   * 🔴 显式指定了会话(URL 里有线程根)时**不清空选中**(2026-11-06):
+   *    目录不可信只影响「有哪些会话」,不影响「用户在打开哪一条」。
+   *    清空会让页面渲染成「还没有对话」,输入框整块消失。
+   */
+  private async applyHydrateFailure(sessionId: string | undefined): Promise<void> {
+    const keep = sessionId ?? '';
+    this._sessionId.set(keep);
+    this._messages.set(keep ? this.demoRestored(keep) : []);
+    this._state.set('error');
+    this._error.set(`会话目录加载失败:${this.sessions.error() ?? '未知原因'}`);
+    this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
+    this._hasMoreHistory.set(false);
+  }
+
   private demoRestored(sessionId: string): Message[] {
     return this.demoStore()?.get(sessionId) ?? [];
   }

+ 50 - 1
src/app/desktop/pages/chat/chat.ts

@@ -181,7 +181,18 @@ interface ConversationRow {
 
       <!-- ===== 聊天区 ===== -->
       <section class="col main-col" aria-label="消息">
-        @if (!chat.agentId()) {
+        @if (chat.state() === 'loading') {
+          <!--
+            🔴 会话加载中 → 骨架屏(2026-11-14 用户要求「加载完成之后才显示」)。
+               原来这里会先闪一下「开始一段新对话」空态,数据到了再被替换 ——
+               用户看到的就是「进去空白加载」。
+          -->
+          <div class="loading-shell" role="status" aria-live="polite" aria-label="正在加载会话">
+            @for (i of [1, 2, 3]; track i) {
+              <ui-skeleton variant="rect" height="56px" />
+            }
+          </div>
+        } @else if (!chat.agentId()) {
           <!--
             🔴 登录后的默认落地态(2026-11-02 用户要求)。
                原来是「选择一个会话 / 从左侧选择数字生命开始对话」的纯空态 ——
@@ -1215,6 +1226,16 @@ interface ConversationRow {
         margin-top: var(--sp-3);
       }
 
+      /* 会话加载中骨架(2026-11-14):避免闪一下空态再被数据替换 */
+      .loading-shell {
+        display: grid;
+        gap: var(--sp-3);
+        padding: var(--sp-5) var(--sp-4);
+        max-width: 768px;
+        margin: 0 auto;
+        width: 100%;
+      }
+
       /* 无会话空态(2026-11-01):提示先新建对话,不自动建空会话 */
       .no-session {
         display: grid;
@@ -3080,6 +3101,22 @@ export class DkChatPage implements OnDestroy {
   }
 
   private async onRouteChange(id: string | null, sessionId: string | null): Promise<void> {
+    /*
+     * 🔴 参数校验(2026-11-14 用户要求「需要携带参数但没有携带就跳回根页面」)。
+     *
+     * 三种情况:
+     *   ① `:id`(容器)缺失      → 回根页面(由根路由分流到默认端首页)
+     *   ② `:id` 指向不存在的容器 → 同上
+     *   ③ `:sessionId` 指向不存在的会话 → 回落成「打开该容器最近一条」
+     *
+     * ①② 在数据加载完之后才能判断(容器列表要等 `ensureLoaded`),
+     *   所以放在 `Promise.all` 之后。③ 沿用原有逻辑。
+     */
+    if (!id) {
+      void this.router.navigateByUrl('/', { replaceUrl: true });
+      return;
+    }
+
     this.sessions.load();
     await Promise.all([
       this.live.ensureLoaded(),
@@ -3087,6 +3124,18 @@ export class DkChatPage implements OnDestroy {
       // 右栏任务面板依赖全量任务(按 agentId 过滤)
       this.tasks.ensureAllLoaded(),
     ]);
+
+    /*
+     * 容器不存在 → 回根页面。
+     *
+     * ⚠️ 只在**容器列表确实加载出来了**时才判 —— 否则网络慢的情况下
+     * 会把合法链接误判成失效,用户一刷新就被踢回首页。
+     */
+    if (this.live.agents().length > 0 && !this.live.agentById(id)) {
+      void this.router.navigateByUrl('/', { replaceUrl: true });
+      return;
+    }
+
     if (id) {
       /*
        * `sessionId === 'new'` = 「新建对话」入口。

+ 18 - 2
src/app/pages/entry/entry.ts

@@ -1,16 +1,23 @@
 import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
 import { Router } from '@angular/router';
 import { DeviceService } from '../../core/device.service';
+import { SessionService } from '../../core/session.service';
 
 /**
  * 入口分流页(SPEC FR-01)
  *
- * 行为:
+ * 行为(2026-11-14 重构):
  *   · 按 UA + 视口**一次性**判定访问端
+ *   · **先判登录态**:未登录直接跳 `/login`,不让用户白跑一趟业务页
+ *   · 已登录直接进对应端的业务页,**不经过空白中转**
  *   · **`replaceUrl: true`** 跳转 → 浏览器返回不会弹回本页(AC-01.2)
- *   · 手动端偏好(`fmode_ui_mode_override`)优先于自动识别
  *
  * 🔴 端判定**只在此处发生一次**,之后不随窗口缩放/旋转改变(ADR-004 决策 3)。
+ *
+ * 🔴 为什么在这里判登录而不是靠守卫兜底:
+ *    原实现无条件跳业务页,再由 `authGuard` 重定向到 `/login`。
+ *    结果是「入口 → 业务页 → 登录」两次跳转,中间会闪一下空白/骨架,
+ *    用户看到的就是「进去之后白屏加载」。现在一步到位。
  */
 @Component({
   selector: 'app-entry',
@@ -56,8 +63,17 @@ import { DeviceService } from '../../core/device.service';
 export class EntryComponent implements OnInit {
   private readonly device = inject(DeviceService);
   private readonly router = inject(Router);
+  private readonly session = inject(SessionService);
 
   ngOnInit(): void {
+    /*
+     * 🔴 先判登录(2026-11-14 用户要求「登录检测成功自动进入对应页面,
+     *    不空白加载」):未登录一步跳到登录页,不做「业务页 → 守卫 → 登录」两跳。
+     */
+    if (!this.session.isLoggedIn()) {
+      void this.router.navigateByUrl('/login', { replaceUrl: true });
+      return;
+    }
     void this.router.navigateByUrl(this.device.entryPath(), { replaceUrl: true });
   }
 }