{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-ui/dr-card-ui/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-ui/dr-card-ui/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-ui/dr-card-ui/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/dangjingtao/dr-card-ui/commit/460b6015441d01fe918fb26065a3ecad246f87dd",
        "commitHash": "460b6015441d01fe918fb26065a3ecad246f87dd",
        "prHref": "https://github.com/dangjingtao/dr-card-ui/pull/183",
        "prTitle": "release: #171 promote real nonprod OSS publish attempt to test",
        "prBaseHash": "553fb4b7a7bf615b18f653cd2b86afafaad15a9a",
        "buildHref": "https://github.com/dangjingtao/dr-card-ui/actions/runs/38031704380"
      },
      "gitCommit": {
        "shortHash": "460b601",
        "hash": "460b6015441d01fe918fb26065a3ecad246f87dd",
        "subject": "Merge 3dffdaeb73272fbea207559fffe3238b83d79e4c into 553fb4b7a7bf615b18f653cd2b86afafaad15a9a",
        "body": "Merge 3dffdaeb73272fbea207559fffe3238b83d79e4c into 553fb4b7a7bf615b18f653cd2b86afafaad15a9a\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1791614380000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1791614380000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/.github/workflows/oss-web-nonprod.yml b/.github/workflows/oss-web-nonprod.yml\nindex 53a803f..eea8c7c 100644\n--- a/.github/workflows/oss-web-nonprod.yml\n+++ b/.github/workflows/oss-web-nonprod.yml\n@@ -71,16 +71,11 @@ jobs:\n             exit 1\n           fi\n \n-  rollout-status:\n-    name: Report nonprod activation state\n-    if: github.event_name == 'push' && vars.OSS_WEB_NONPROD_ENABLED != 'true'\n-    runs-on: ubuntu-latest\n-    steps:\n-      - run: echo \"::notice::OSS nonproduction publishing is disabled. Configure oss-ui/oss-dev/oss-test and set repository variable OSS_WEB_NONPROD_ENABLED=true to activate uploads; package checks still run.\"\n-\n   deploy:\n     name: Publish fixed nonprod OSS path\n-    if: github.event_name == 'push' && vars.OSS_WEB_NONPROD_ENABLED == 'true' && (github.ref_name == 'preview' || github.ref_name == 'dev' || github.ref_name == 'test')\n+    # #171: a push must actually attempt the OSS deployment instead of\n+    # silently showing green CI while the publish job is switched off.\n+    if: github.event_name == 'push' && (github.ref_name == 'preview' || github.ref_name == 'dev' || github.ref_name == 'test')\n     needs: package-check\n     runs-on: ubuntu-latest\n     timeout-minutes: 25\ndiff --git a/docs/deploy/oss-web-fixed-nonprod.md b/docs/deploy/oss-web-fixed-nonprod.md\nindex 2fd454e..391d58c 100644\n--- a/docs/deploy/oss-web-fixed-nonprod.md\n+++ b/docs/deploy/oss-web-fixed-nonprod.md\n@@ -21,7 +21,7 @@ Bucket `kbs-sdl`（`cn-guangzhou`）：\n \n ## GitHub 环境准备（必须由仓库/云账号管理员完成）\n \n-默认安全状态：新非生产部署**不立即启用**，PR/branch CI 仍会做无密钥构建验证。管理员在下面三个 Environment 和测试 API/HTTPS 配置全部就绪后，在仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `OSS_WEB_NONPROD_ENABLED=true` 才允许自动上传。禁用时 push 工作流明确输出 Notice；**启用后若凭据或 test API 缺失则部署失败**，不跳过校验，不回退到生产密钥。\n+**#171 修订**：取消 `OSS_WEB_NONPROD_ENABLED` 额外开关。非生产分支 push 后先进行无密钥包检查，通过就**真正尝试**所属环境的 OSS 上传；若对应 GitHub Environment 凭据缺失，作业明确失败并输出配置要求，**不再以 skipped 假装已部署**。仍不借用生产密钥，也不自动删除任何 OSS 文件。\n \n 首次执行自动部署前，在 GitHub Settings → Environments 创建：\n \ndiff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 6764bc0..a790383 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -14,7 +14,7 @@\n - H5 统一通过 `src/services/nativeBridge.ts` 获取并验证结果，业务页面不得直接访问宿主对象。\n - OAuth 开发登录：`POST {VITE_API_BASE_URL}/api/oauth/login`，请求体 `{ salt, token }`；成功响应兼容旧的 `{ userInfo, accessToken }`，以及当前后台返回的 `{ code: 0, data: { userInfo, accessToken, ... } }`；后续同源 API 请求使用 `Authorization: Bearer <accessToken>`。\n - 正式 H5 根首页在 `VITE_DATA_MODE=api` 时先完成登录；仅 `accessToken` 暂存于 `sessionStorage`，`userInfo` 保留在当前文档内存。Mock 模式保留独立浏览器预览，不模拟真实登录。\n-- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。重登失败进入 `/error?reason=auth`，不循环。\n+- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。若这次重登本身再次返回 401，或重登成功后重放原请求仍返回 401，则判定当前用户会话已失效：H5 弹出「用户信息过期」Toast，并调用 Native `closeWebView()` 关闭当前 WebView，不再继续重登或停留在当前业务页。重登过程中的非 401 失败仍进入 `/error?reason=auth`，不循环。\n - 2026-09-28 后端统一响应协议：成功码 `code: 0`（兼容原生；`GET /api/user/detail` 此前 2026-09-24 实测的 `code: 200` 已由后端统一改回 0）；失败为非 0 code + `message` 字段（无 `msg` / `status`）。正式接口共用 `parseApiEnvelope` 解析。\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 2578478..ee72d0f 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useLayoutEffect, useRef } from 'react'\n+import { useEffect, useLayoutEffect, useRef, useState } from 'react'\n import {\n   Navigate,\n   Outlet,\n@@ -16,7 +16,9 @@ import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n import { useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n-import { AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n+import Toast from '../components/ui/Toast'\n+import { closeWebView } from '../services/nativeBridge'\n+import { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n \n type H5RouteTransitionKind = 'none' | 'tab' | 'forward' | 'back'\n \n@@ -111,6 +113,8 @@ export default function MobileLayout() {\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n+  const [authExpired, setAuthExpired] = useState(false)\n+  const authExpiredHandledRef = useRef(false)\n   const showLegacyNav = isLegacyTabPath(location.pathname)\n   const showNav = showLegacyNav || isFormalH5TabPath(location.pathname)\n   const route = findRouteByPathname(location.pathname)\n@@ -122,9 +126,22 @@ export default function MobileLayout() {\n     if (!activeFormalH5) return\n \n     const handleAuthFailure = () => navigate('/error?reason=auth', { replace: true })\n+    const handleAuthExpired = () => {\n+      if (authExpiredHandledRef.current) return\n+      authExpiredHandledRef.current = true\n+      setAuthExpired(true)\n+      window.setTimeout(() => {\n+        void closeWebView().catch((error) => {\n+          console.error('[auth-expired-close]', error)\n+        })\n+      }, 800)\n+    }\n+\n     window.addEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+    window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n     return () => {\n       window.removeEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+      window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n       setAuthFlowEnabled(false)\n     }\n   }, [activeFormalH5, navigate])\n@@ -220,27 +237,34 @@ export default function MobileLayout() {\n           : undefined\n \n   return (\n-    <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n-      {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n-      <div className=\"min-w-0 shrink-0\">\n-        {titleBarMode !== 'hidden' && (\n-          <TitleBar\n-            title={title}\n-            leadingAction={leadingAction}\n-            onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n-            action={titleAction}\n-            actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n-            fullWidth={activeFormalH5}\n-          />\n-        )}\n+    <>\n+      {authExpired && (\n+        <div className=\"pointer-events-none fixed inset-x-0 bottom-24 z-[100] flex justify-center px-6\">\n+          <Toast message=\"用户信息过期\" />\n+        </div>\n+      )}\n+      <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n+        {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n+        <div className=\"min-w-0 shrink-0\">\n+          {titleBarMode !== 'hidden' && (\n+            <TitleBar\n+              title={title}\n+              leadingAction={leadingAction}\n+              onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n+              action={titleAction}\n+              actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n+              fullWidth={activeFormalH5}\n+            />\n+          )}\n+        </div>\n+        <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n+          {isActiveFormalH5Route(route) && (\n+            <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n+          )}\n+          <H5RouteOutlet />\n+        </div>\n+        {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n       </div>\n-      <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n-        {isActiveFormalH5Route(route) && (\n-          <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n-        )}\n-        <H5RouteOutlet />\n-      </div>\n-      {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n-    </div>\n+    </>\n   )\n }\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 2bf7570..060978f 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,6 +1,6 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError } from '../http/appError'\n+import { createContractError, isAppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n import {\n   createHttpClient,\n@@ -10,6 +10,7 @@ import {\n } from '../http/httpClient'\n \n const AUTH_FAILURE_EVENT = 'dr-card-ui:auth-failure'\n+const AUTH_EXPIRED_EVENT = 'dr-card-ui:auth-expired'\n const LOGIN_PATH = '/api/oauth/login'\n \n export interface AuthSession {\n@@ -136,7 +137,13 @@ async function reauthenticateAfterUnauthorized() {\n     await authenticate(true)\n   } catch (error) {\n     clearAuthSession()\n-    if (typeof window !== 'undefined') window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    if (typeof window !== 'undefined') {\n+      const eventName =\n+        isAppError(error) && error.status === 401\n+          ? AUTH_EXPIRED_EVENT\n+          : AUTH_FAILURE_EVENT\n+      window.dispatchEvent(new Event(eventName))\n+    }\n     throw error\n   }\n }\n@@ -158,8 +165,8 @@ setHttpUnauthorizedHandler(() => {\n setHttpAuthFailureHandler(() => {\n   if (authFlowEnabled && typeof window !== 'undefined') {\n     clearAuthSession()\n-    window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))\n   }\n })\n \n-export { AUTH_FAILURE_EVENT }\n+export { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT }\ndiff --git a/src/services/http/httpClient.test.ts b/src/services/http/httpClient.test.ts\nindex 84ddd8a..a8306f1 100644\n--- a/src/services/http/httpClient.test.ts\n+++ b/src/services/http/httpClient.test.ts\n@@ -151,14 +151,18 @@ describe('HTTP foundation', () => {\n     expect(attempts).toBe(2)\n   })\n \n-  it('recognizes a top-level business code 401 and never retries the replay again', async () => {\n+  it('recognizes a top-level business code 401 and reports terminal auth failure after one replay', async () => {\n     let attempts = 0\n     let renewals = 0\n+    let failures = 0\n     const client = createHttpClient({\n       baseURL: 'https://api.example.test',\n       onUnauthorized: () => {\n         renewals += 1\n       },\n+      onAuthFailure: () => {\n+        failures += 1\n+      },\n       adapter: async (config) => {\n         attempts += 1\n         return {\n@@ -177,6 +181,7 @@ describe('HTTP foundation', () => {\n     })\n     expect(attempts).toBe(2)\n     expect(renewals).toBe(1)\n+    expect(failures).toBe(1)\n   })\n \n   it('reports a second unauthorized response without starting another login loop', async () => {",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-ui/dr-card-ui/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/dangjingtao/dr-card-ui/commit/460b6015441d01fe918fb26065a3ecad246f87dd",
            "commitHash": "460b6015441d01fe918fb26065a3ecad246f87dd",
            "prHref": "https://github.com/dangjingtao/dr-card-ui/pull/183",
            "prTitle": "release: #171 promote real nonprod OSS publish attempt to test",
            "prBaseHash": "553fb4b7a7bf615b18f653cd2b86afafaad15a9a",
            "buildHref": "https://github.com/dangjingtao/dr-card-ui/actions/runs/38031704380"
          },
          "gitCommit": {
            "shortHash": "460b601",
            "hash": "460b6015441d01fe918fb26065a3ecad246f87dd",
            "subject": "Merge 3dffdaeb73272fbea207559fffe3238b83d79e4c into 553fb4b7a7bf615b18f653cd2b86afafaad15a9a",
            "body": "Merge 3dffdaeb73272fbea207559fffe3238b83d79e4c into 553fb4b7a7bf615b18f653cd2b86afafaad15a9a\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1791614380000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1791614380000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/.github/workflows/oss-web-nonprod.yml b/.github/workflows/oss-web-nonprod.yml\nindex 53a803f..eea8c7c 100644\n--- a/.github/workflows/oss-web-nonprod.yml\n+++ b/.github/workflows/oss-web-nonprod.yml\n@@ -71,16 +71,11 @@ jobs:\n             exit 1\n           fi\n \n-  rollout-status:\n-    name: Report nonprod activation state\n-    if: github.event_name == 'push' && vars.OSS_WEB_NONPROD_ENABLED != 'true'\n-    runs-on: ubuntu-latest\n-    steps:\n-      - run: echo \"::notice::OSS nonproduction publishing is disabled. Configure oss-ui/oss-dev/oss-test and set repository variable OSS_WEB_NONPROD_ENABLED=true to activate uploads; package checks still run.\"\n-\n   deploy:\n     name: Publish fixed nonprod OSS path\n-    if: github.event_name == 'push' && vars.OSS_WEB_NONPROD_ENABLED == 'true' && (github.ref_name == 'preview' || github.ref_name == 'dev' || github.ref_name == 'test')\n+    # #171: a push must actually attempt the OSS deployment instead of\n+    # silently showing green CI while the publish job is switched off.\n+    if: github.event_name == 'push' && (github.ref_name == 'preview' || github.ref_name == 'dev' || github.ref_name == 'test')\n     needs: package-check\n     runs-on: ubuntu-latest\n     timeout-minutes: 25\ndiff --git a/docs/deploy/oss-web-fixed-nonprod.md b/docs/deploy/oss-web-fixed-nonprod.md\nindex 2fd454e..391d58c 100644\n--- a/docs/deploy/oss-web-fixed-nonprod.md\n+++ b/docs/deploy/oss-web-fixed-nonprod.md\n@@ -21,7 +21,7 @@ Bucket `kbs-sdl`（`cn-guangzhou`）：\n \n ## GitHub 环境准备（必须由仓库/云账号管理员完成）\n \n-默认安全状态：新非生产部署**不立即启用**，PR/branch CI 仍会做无密钥构建验证。管理员在下面三个 Environment 和测试 API/HTTPS 配置全部就绪后，在仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `OSS_WEB_NONPROD_ENABLED=true` 才允许自动上传。禁用时 push 工作流明确输出 Notice；**启用后若凭据或 test API 缺失则部署失败**，不跳过校验，不回退到生产密钥。\n+**#171 修订**：取消 `OSS_WEB_NONPROD_ENABLED` 额外开关。非生产分支 push 后先进行无密钥包检查，通过就**真正尝试**所属环境的 OSS 上传；若对应 GitHub Environment 凭据缺失，作业明确失败并输出配置要求，**不再以 skipped 假装已部署**。仍不借用生产密钥，也不自动删除任何 OSS 文件。\n \n 首次执行自动部署前，在 GitHub Settings → Environments 创建：\n \ndiff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 6764bc0..a790383 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -14,7 +14,7 @@\n - H5 统一通过 `src/services/nativeBridge.ts` 获取并验证结果，业务页面不得直接访问宿主对象。\n - OAuth 开发登录：`POST {VITE_API_BASE_URL}/api/oauth/login`，请求体 `{ salt, token }`；成功响应兼容旧的 `{ userInfo, accessToken }`，以及当前后台返回的 `{ code: 0, data: { userInfo, accessToken, ... } }`；后续同源 API 请求使用 `Authorization: Bearer <accessToken>`。\n - 正式 H5 根首页在 `VITE_DATA_MODE=api` 时先完成登录；仅 `accessToken` 暂存于 `sessionStorage`，`userInfo` 保留在当前文档内存。Mock 模式保留独立浏览器预览，不模拟真实登录。\n-- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。重登失败进入 `/error?reason=auth`，不循环。\n+- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。若这次重登本身再次返回 401，或重登成功后重放原请求仍返回 401，则判定当前用户会话已失效：H5 弹出「用户信息过期」Toast，并调用 Native `closeWebView()` 关闭当前 WebView，不再继续重登或停留在当前业务页。重登过程中的非 401 失败仍进入 `/error?reason=auth`，不循环。\n - 2026-09-28 后端统一响应协议：成功码 `code: 0`（兼容原生；`GET /api/user/detail` 此前 2026-09-24 实测的 `code: 200` 已由后端统一改回 0）；失败为非 0 code + `message` 字段（无 `msg` / `status`）。正式接口共用 `parseApiEnvelope` 解析。\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 2578478..ee72d0f 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useLayoutEffect, useRef } from 'react'\n+import { useEffect, useLayoutEffect, useRef, useState } from 'react'\n import {\n   Navigate,\n   Outlet,\n@@ -16,7 +16,9 @@ import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n import { useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n-import { AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n+import Toast from '../components/ui/Toast'\n+import { closeWebView } from '../services/nativeBridge'\n+import { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n \n type H5RouteTransitionKind = 'none' | 'tab' | 'forward' | 'back'\n \n@@ -111,6 +113,8 @@ export default function MobileLayout() {\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n+  const [authExpired, setAuthExpired] = useState(false)\n+  const authExpiredHandledRef = useRef(false)\n   const showLegacyNav = isLegacyTabPath(location.pathname)\n   const showNav = showLegacyNav || isFormalH5TabPath(location.pathname)\n   const route = findRouteByPathname(location.pathname)\n@@ -122,9 +126,22 @@ export default function MobileLayout() {\n     if (!activeFormalH5) return\n \n     const handleAuthFailure = () => navigate('/error?reason=auth', { replace: true })\n+    const handleAuthExpired = () => {\n+      if (authExpiredHandledRef.current) return\n+      authExpiredHandledRef.current = true\n+      setAuthExpired(true)\n+      window.setTimeout(() => {\n+        void closeWebView().catch((error) => {\n+          console.error('[auth-expired-close]', error)\n+        })\n+      }, 800)\n+    }\n+\n     window.addEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+    window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n     return () => {\n       window.removeEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+      window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n       setAuthFlowEnabled(false)\n     }\n   }, [activeFormalH5, navigate])\n@@ -220,27 +237,34 @@ export default function MobileLayout() {\n           : undefined\n \n   return (\n-    <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n-      {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n-      <div className=\"min-w-0 shrink-0\">\n-        {titleBarMode !== 'hidden' && (\n-          <TitleBar\n-            title={title}\n-            leadingAction={leadingAction}\n-            onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n-            action={titleAction}\n-            actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n-            fullWidth={activeFormalH5}\n-          />\n-        )}\n+    <>\n+      {authExpired && (\n+        <div className=\"pointer-events-none fixed inset-x-0 bottom-24 z-[100] flex justify-center px-6\">\n+          <Toast message=\"用户信息过期\" />\n+        </div>\n+      )}\n+      <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n+        {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n+        <div className=\"min-w-0 shrink-0\">\n+          {titleBarMode !== 'hidden' && (\n+            <TitleBar\n+              title={title}\n+              leadingAction={leadingAction}\n+              onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n+              action={titleAction}\n+              actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n+              fullWidth={activeFormalH5}\n+            />\n+          )}\n+        </div>\n+        <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n+          {isActiveFormalH5Route(route) && (\n+            <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n+          )}\n+          <H5RouteOutlet />\n+        </div>\n+        {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n       </div>\n-      <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n-        {isActiveFormalH5Route(route) && (\n-          <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n-        )}\n-        <H5RouteOutlet />\n-      </div>\n-      {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n-    </div>\n+    </>\n   )\n }\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 2bf7570..060978f 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,6 +1,6 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError } from '../http/appError'\n+import { createContractError, isAppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n import {\n   createHttpClient,\n@@ -10,6 +10,7 @@ import {\n } from '../http/httpClient'\n \n const AUTH_FAILURE_EVENT = 'dr-card-ui:auth-failure'\n+const AUTH_EXPIRED_EVENT = 'dr-card-ui:auth-expired'\n const LOGIN_PATH = '/api/oauth/login'\n \n export interface AuthSession {\n@@ -136,7 +137,13 @@ async function reauthenticateAfterUnauthorized() {\n     await authenticate(true)\n   } catch (error) {\n     clearAuthSession()\n-    if (typeof window !== 'undefined') window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    if (typeof window !== 'undefined') {\n+      const eventName =\n+        isAppError(error) && error.status === 401\n+          ? AUTH_EXPIRED_EVENT\n+          : AUTH_FAILURE_EVENT\n+      window.dispatchEvent(new Event(eventName))\n+    }\n     throw error\n   }\n }\n@@ -158,8 +165,8 @@ setHttpUnauthorizedHandler(() => {\n setHttpAuthFailureHandler(() => {\n   if (authFlowEnabled && typeof window !== 'undefined') {\n     clearAuthSession()\n-    window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))\n   }\n })\n \n-export { AUTH_FAILURE_EVENT }\n+export { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT }\ndiff --git a/src/services/http/httpClient.test.ts b/src/services/http/httpClient.test.ts\nindex 84ddd8a..a8306f1 100644\n--- a/src/services/http/httpClient.test.ts\n+++ b/src/services/http/httpClient.test.ts\n@@ -151,14 +151,18 @@ describe('HTTP foundation', () => {\n     expect(attempts).toBe(2)\n   })\n \n-  it('recognizes a top-level business code 401 and never retries the replay again', async () => {\n+  it('recognizes a top-level business code 401 and reports terminal auth failure after one replay', async () => {\n     let attempts = 0\n     let renewals = 0\n+    let failures = 0\n     const client = createHttpClient({\n       baseURL: 'https://api.example.test',\n       onUnauthorized: () => {\n         renewals += 1\n       },\n+      onAuthFailure: () => {\n+        failures += 1\n+      },\n       adapter: async (config) => {\n         attempts += 1\n         return {\n@@ -177,6 +181,7 @@ describe('HTTP foundation', () => {\n     })\n     expect(attempts).toBe(2)\n     expect(renewals).toBe(1)\n+    expect(failures).toBe(1)\n   })\n \n   it('reports a second unauthorized response without starting another login loop', async () => {",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-ui/dr-card-ui/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 28,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:28.382Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 719,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:28.392Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 701,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:28.441Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 663,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:29.316Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 662,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:29.340Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 643,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:29.989Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 658,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-10T06:40:30.018Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-10T06:40:27.707Z",
    "duration": 3010.191,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}