{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/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/sanchuang-dev/dr-card-web/commit/772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
        "commitHash": "772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/250",
        "prTitle": "chore(release): promote dev to test — 2026-10-19",
        "prBaseHash": "d884e41eddd0fed4dc808ea2cff6d34d4a070808",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39231446602"
      },
      "gitCommit": {
        "shortHash": "772bec2",
        "hash": "772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
        "subject": "Merge ef0c5e49dd4cb70e441dbc908a6d946d1f5bede4 into d884e41eddd0fed4dc808ea2cff6d34d4a070808",
        "body": "Merge ef0c5e49dd4cb70e441dbc908a6d946d1f5bede4 into d884e41eddd0fed4dc808ea2cff6d34d4a070808\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792402639000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792402639000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/opencode.json b/opencode.json\ndeleted file mode 100644\nindex 720ece5..0000000\n--- a/opencode.json\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-{\n-  \"$schema\": \"https://opencode.ai/config.json\"\n-}\ndiff --git a/src/app/state/notifications.api.test.ts b/src/app/state/notifications.api.test.ts\nindex 8f0251b..366571c 100644\n--- a/src/app/state/notifications.api.test.ts\n+++ b/src/app/state/notifications.api.test.ts\n@@ -22,7 +22,7 @@ vi.mock('../../services/notices', async importOriginal => {\n \n import {\n   loadMoreRemoteNotices, loadRemoteNoticeDetail, markAllNotificationsRead,\n-  refreshRemoteNotices, resetNotifications, useApiNoticeStore,\n+  refreshRemoteNotices, refreshRemoteUnreadCount, resetNotifications, useApiNoticeStore,\n } from './notifications'\n \n const data = (id: number, isRead = 0) => ({\n@@ -45,6 +45,18 @@ describe('#122 remote notice state', () => {\n     act(() => resetNotifications())\n   })\n \n+  it('refetches the current user count after a 401 transparently rotates the auth token', async () => {\n+    mocks.fetchCounts.mockImplementationOnce(async () => {\n+      mocks.token = 'B' // Axios renewed the stored token while the count request was in flight.\n+      return { total: 7, items: [] }\n+    }).mockResolvedValueOnce({ total: 4, items: [] })\n+\n+    await act(async () => { await refreshRemoteUnreadCount() })\n+    expect(mocks.fetchCounts).toHaveBeenCalledTimes(2)\n+    expect(useApiNoticeStore.getState().countLoaded).toBe(true)\n+    expect(useApiNoticeStore.getState().unreadCount).toBe(4)\n+  })\n+\n   it('loads backend notices with paging, not the preview fixture list', async () => {\n     mocks.fetchPage.mockResolvedValueOnce(page([31], 1, 2))\n       .mockResolvedValueOnce(page([32], 2, 2))\ndiff --git a/src/app/state/notifications.ts b/src/app/state/notifications.ts\nindex 4dcaa37..31c0f4c 100644\n--- a/src/app/state/notifications.ts\n+++ b/src/app/state/notifications.ts\n@@ -90,14 +90,22 @@ async function ensureNoticeSession(): Promise<void> {\n   if (!getAuthSession()?.accessToken) await bootstrapAuthSession()\n }\n \n-export async function refreshRemoteUnreadCount(): Promise<void> {\n+export async function refreshRemoteUnreadCount(retryAfterSessionChange = true): Promise<void> {\n   if (!isApi) return\n   try {\n     await ensureNoticeSession()\n     const epoch = currentGeneration()\n     const seq = ++countSequence\n     const counts = await fetchNoticeUnreadCounts()\n-    if (currentGeneration() === epoch && countSequence === seq) {\n+    if (currentGeneration() !== epoch) {\n+      // Axios can renew an expired Bearer token and retry this request successfully.\n+      // Never apply an earlier session's count: fetch once more for the new token instead.\n+      if (retryAfterSessionChange && getAuthSession()?.accessToken) {\n+        await refreshRemoteUnreadCount(false)\n+      }\n+      return\n+    }\n+    if (countSequence === seq) {\n       useApiNoticeStore.setState({ unreadCount: counts.total, countLoaded: true })\n     }\n   } catch {\ndiff --git a/src/components/mobile/NotificationBell.test.tsx b/src/components/mobile/NotificationBell.test.tsx\nnew file mode 100644\nindex 0000000..9c95965\n--- /dev/null\n+++ b/src/components/mobile/NotificationBell.test.tsx\n@@ -0,0 +1,34 @@\n+import { describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen } from '@testing-library/react'\n+import NotificationBell from './NotificationBell'\n+\n+describe('member-center notification bell', () => {\n+  it('shows a red unread count, preserves the notice navigation and exposes the real count accessibly', () => {\n+    const onClick = vi.fn()\n+    const { container } = render(<NotificationBell unreadCount={8} countLoaded showBadge onClick={onClick} />)\n+    const button = screen.getByRole('button', { name: '通知，8 条未读' })\n+    const badge = container.querySelector('span[aria-hidden=\"true\"]')\n+    expect(button.textContent).toBe('8')\n+    expect(badge?.textContent).toBe('8')\n+    expect(badge?.className).toContain('bg-[#EF4444]')\n+    fireEvent.click(button)\n+    expect(onClick).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('hides the badge for zero or not-yet-loaded API counts, without displaying a fake zero', () => {\n+    const { rerender } = render(<NotificationBell unreadCount={0} countLoaded showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+    rerender(<NotificationBell unreadCount={4} countLoaded={false} showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+  })\n+\n+  it('keeps the original uncounted bell on other routes sharing the title bar', () => {\n+    render(<NotificationBell unreadCount={8} countLoaded onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+  })\n+\n+  it('caps only the visual number at 99+ while preserving the exact count for accessibility', () => {\n+    render(<NotificationBell unreadCount={123} countLoaded showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知，123 条未读' }).textContent).toBe('99+')\n+  })\n+})\ndiff --git a/src/components/mobile/NotificationBell.tsx b/src/components/mobile/NotificationBell.tsx\nnew file mode 100644\nindex 0000000..bd9758e\n--- /dev/null\n+++ b/src/components/mobile/NotificationBell.tsx\n@@ -0,0 +1,32 @@\n+import { Bell } from 'lucide-react'\n+\n+interface NotificationBellProps {\n+  unreadCount: number\n+  countLoaded: boolean\n+  onClick: () => void\n+  showBadge?: boolean\n+}\n+\n+/** A single notice badge for the shared title bar; the API store owns the count. */\n+export default function NotificationBell({ unreadCount, countLoaded, onClick, showBadge = false }: NotificationBellProps) {\n+  const hasUnread = showBadge && countLoaded && unreadCount > 0\n+\n+  return (\n+    <button\n+      type=\"button\"\n+      aria-label={hasUnread ? `通知，${unreadCount} 条未读` : '通知'}\n+      onClick={onClick}\n+      className=\"relative flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\"\n+    >\n+      <Bell className=\"h-[22px] w-[22px]\" aria-hidden />\n+      {hasUnread && (\n+        <span\n+          aria-hidden=\"true\"\n+          className=\"absolute right-0 top-0 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[#EF4444] px-[3px] text-[10px] font-semibold leading-none tabular-nums text-white\"\n+        >\n+          {unreadCount > 99 ? '99+' : unreadCount}\n+        </span>\n+      )}\n+    </button>\n+  )\n+}\ndiff --git a/src/layouts/MobileLayout.notifications.test.tsx b/src/layouts/MobileLayout.notifications.test.tsx\nnew file mode 100644\nindex 0000000..7078b49\n--- /dev/null\n+++ b/src/layouts/MobileLayout.notifications.test.tsx\n@@ -0,0 +1,79 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { act, cleanup, render, screen } from '@testing-library/react'\n+import { createMemoryRouter, RouterProvider } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+\n+const mocks = vi.hoisted(() => ({\n+  refresh: vi.fn().mockResolvedValue(undefined),\n+  setAuthFlowEnabled: vi.fn(),\n+  unreadCount: 6,\n+  countLoaded: true,\n+}))\n+\n+vi.mock('../app/state/notifications', () => ({\n+  useNotifications: () => ({ unreadCount: mocks.unreadCount, countLoaded: mocks.countLoaded }),\n+  refreshRemoteUnreadCount: mocks.refresh,\n+}))\n+vi.mock('../components/mobile/TitleBar', () => ({\n+  default: ({ action }: { action: ReactNode }) => <header>{action}</header>,\n+}))\n+vi.mock('../components/mobile/BottomNav', () => ({ default: () => null }))\n+vi.mock('../components/mobile/H5ScrollRestoration', () => ({ default: () => null }))\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  protectedFixtureRedirect: () => null,\n+  useOverlay: () => ({ open: vi.fn() }),\n+}))\n+vi.mock('../services/auth/session', () => ({\n+  AUTH_EXPIRED_EVENT: 'test:expired',\n+  AUTH_FAILURE_EVENT: 'test:failed',\n+  setAuthFlowEnabled: mocks.setAuthFlowEnabled,\n+}))\n+vi.mock('../services/nativeBridge', () => ({ closeWebView: vi.fn() }))\n+\n+import MobileLayout from './MobileLayout'\n+\n+function showShell(initialPath: string) {\n+  const router = createMemoryRouter([\n+    {\n+      path: '/',\n+      element: <MobileLayout />,\n+      children: [\n+        { path: 'profile', element: <p>会员首页</p> },\n+        { path: 'points', element: <p>泡泡值</p> },\n+        { path: 'dearseed', element: <p>诗得丽</p> },\n+        { path: 'notifications', element: <p>消息列表</p> },\n+      ],\n+    },\n+  ], { initialEntries: [initialPath] })\n+  render(<RouterProvider router={router} />)\n+  return router\n+}\n+\n+afterEach(() => {\n+  cleanup()\n+  mocks.refresh.mockClear()\n+  mocks.setAuthFlowEnabled.mockClear()\n+  mocks.unreadCount = 6\n+  mocks.countLoaded = true\n+})\n+\n+describe('member-center unread badge lifecycle', () => {\n+  it('shows the shared notice count on the profile header and refreshes once on return to the tab', async () => {\n+    const router = showShell('/profile')\n+    expect(screen.getByRole('button', { name: '通知，6 条未读' }).textContent).toBe('6')\n+    // useNotifications already performs its initial request; do not double-query on mount.\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+\n+    await act(async () => { await router.navigate('/dearseed') })\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+    await act(async () => { await router.navigate('/points') })\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+    await act(async () => { await router.navigate('/profile') })\n+    expect(mocks.refresh).toHaveBeenCalledTimes(1)\n+\n+    await act(async () => { await router.navigate('/notifications') })\n+    await act(async () => { await router.navigate('/profile') })\n+    expect(mocks.refresh).toHaveBeenCalledTimes(2)\n+  })\n+})\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 6e68042..536a053 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -7,13 +7,14 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, Settings, UserRound } from 'lucide-react'\n+import { Settings, UserRound } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n+import NotificationBell from '../components/mobile/NotificationBell'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n import { getRouteScope, isActiveFormalH5Route, isFormalH5TabPath } from '../app/router/routeScope'\n import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n-import { useNotifications } from '../app/state/notifications'\n+import { refreshRemoteUnreadCount, useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n import Toast from '../components/ui/Toast'\n@@ -110,6 +111,7 @@ export default function MobileLayout() {\n   const location = useLocation()\n   const navigate = useNavigate()\n   const previousShellPathname = useRef(location.pathname)\n+  const previousNoticePathname = useRef(location.pathname)\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n@@ -164,6 +166,14 @@ export default function MobileLayout() {\n   const isNotificationsPage = location.pathname === '/notifications'\n   const allNotificationsRead = countLoaded && unreadCount === 0\n \n+  // The shared notice hook fetches on initial mount/focus. Refresh only when returning\n+  // to the member-center tab, avoiding a duplicate request on the first render.\n+  useEffect(() => {\n+    const enteringProfile = previousNoticePathname.current !== '/profile' && location.pathname === '/profile'\n+    previousNoticePathname.current = location.pathname\n+    if (enteringProfile) void refreshRemoteUnreadCount()\n+  }, [location.pathname])\n+\n   useLayoutEffect(() => {\n     previousShellPathname.current = location.pathname\n   }, [location.pathname])\n@@ -216,9 +226,12 @@ export default function MobileLayout() {\n       )\n       : route?.titleBarAction === 'notifications'\n         ? (\n-          <button type=\"button\" aria-label=\"通知\" onClick={() => navigateShell('/notifications')} className=\"flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\">\n-            <Bell className=\"h-[22px] w-[22px]\" />\n-          </button>\n+          <NotificationBell\n+            unreadCount={unreadCount}\n+            countLoaded={countLoaded}\n+            showBadge={location.pathname === '/profile'}\n+            onClick={() => navigateShell('/notifications')}\n+          />\n         )\n         : undefined",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
            "commitHash": "772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/250",
            "prTitle": "chore(release): promote dev to test — 2026-10-19",
            "prBaseHash": "d884e41eddd0fed4dc808ea2cff6d34d4a070808",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39231446602"
          },
          "gitCommit": {
            "shortHash": "772bec2",
            "hash": "772bec2d31f9c3c4ee7bd6c5fb06d06ffaf81f6c",
            "subject": "Merge ef0c5e49dd4cb70e441dbc908a6d946d1f5bede4 into d884e41eddd0fed4dc808ea2cff6d34d4a070808",
            "body": "Merge ef0c5e49dd4cb70e441dbc908a6d946d1f5bede4 into d884e41eddd0fed4dc808ea2cff6d34d4a070808\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792402639000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792402639000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/opencode.json b/opencode.json\ndeleted file mode 100644\nindex 720ece5..0000000\n--- a/opencode.json\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-{\n-  \"$schema\": \"https://opencode.ai/config.json\"\n-}\ndiff --git a/src/app/state/notifications.api.test.ts b/src/app/state/notifications.api.test.ts\nindex 8f0251b..366571c 100644\n--- a/src/app/state/notifications.api.test.ts\n+++ b/src/app/state/notifications.api.test.ts\n@@ -22,7 +22,7 @@ vi.mock('../../services/notices', async importOriginal => {\n \n import {\n   loadMoreRemoteNotices, loadRemoteNoticeDetail, markAllNotificationsRead,\n-  refreshRemoteNotices, resetNotifications, useApiNoticeStore,\n+  refreshRemoteNotices, refreshRemoteUnreadCount, resetNotifications, useApiNoticeStore,\n } from './notifications'\n \n const data = (id: number, isRead = 0) => ({\n@@ -45,6 +45,18 @@ describe('#122 remote notice state', () => {\n     act(() => resetNotifications())\n   })\n \n+  it('refetches the current user count after a 401 transparently rotates the auth token', async () => {\n+    mocks.fetchCounts.mockImplementationOnce(async () => {\n+      mocks.token = 'B' // Axios renewed the stored token while the count request was in flight.\n+      return { total: 7, items: [] }\n+    }).mockResolvedValueOnce({ total: 4, items: [] })\n+\n+    await act(async () => { await refreshRemoteUnreadCount() })\n+    expect(mocks.fetchCounts).toHaveBeenCalledTimes(2)\n+    expect(useApiNoticeStore.getState().countLoaded).toBe(true)\n+    expect(useApiNoticeStore.getState().unreadCount).toBe(4)\n+  })\n+\n   it('loads backend notices with paging, not the preview fixture list', async () => {\n     mocks.fetchPage.mockResolvedValueOnce(page([31], 1, 2))\n       .mockResolvedValueOnce(page([32], 2, 2))\ndiff --git a/src/app/state/notifications.ts b/src/app/state/notifications.ts\nindex 4dcaa37..31c0f4c 100644\n--- a/src/app/state/notifications.ts\n+++ b/src/app/state/notifications.ts\n@@ -90,14 +90,22 @@ async function ensureNoticeSession(): Promise<void> {\n   if (!getAuthSession()?.accessToken) await bootstrapAuthSession()\n }\n \n-export async function refreshRemoteUnreadCount(): Promise<void> {\n+export async function refreshRemoteUnreadCount(retryAfterSessionChange = true): Promise<void> {\n   if (!isApi) return\n   try {\n     await ensureNoticeSession()\n     const epoch = currentGeneration()\n     const seq = ++countSequence\n     const counts = await fetchNoticeUnreadCounts()\n-    if (currentGeneration() === epoch && countSequence === seq) {\n+    if (currentGeneration() !== epoch) {\n+      // Axios can renew an expired Bearer token and retry this request successfully.\n+      // Never apply an earlier session's count: fetch once more for the new token instead.\n+      if (retryAfterSessionChange && getAuthSession()?.accessToken) {\n+        await refreshRemoteUnreadCount(false)\n+      }\n+      return\n+    }\n+    if (countSequence === seq) {\n       useApiNoticeStore.setState({ unreadCount: counts.total, countLoaded: true })\n     }\n   } catch {\ndiff --git a/src/components/mobile/NotificationBell.test.tsx b/src/components/mobile/NotificationBell.test.tsx\nnew file mode 100644\nindex 0000000..9c95965\n--- /dev/null\n+++ b/src/components/mobile/NotificationBell.test.tsx\n@@ -0,0 +1,34 @@\n+import { describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen } from '@testing-library/react'\n+import NotificationBell from './NotificationBell'\n+\n+describe('member-center notification bell', () => {\n+  it('shows a red unread count, preserves the notice navigation and exposes the real count accessibly', () => {\n+    const onClick = vi.fn()\n+    const { container } = render(<NotificationBell unreadCount={8} countLoaded showBadge onClick={onClick} />)\n+    const button = screen.getByRole('button', { name: '通知，8 条未读' })\n+    const badge = container.querySelector('span[aria-hidden=\"true\"]')\n+    expect(button.textContent).toBe('8')\n+    expect(badge?.textContent).toBe('8')\n+    expect(badge?.className).toContain('bg-[#EF4444]')\n+    fireEvent.click(button)\n+    expect(onClick).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('hides the badge for zero or not-yet-loaded API counts, without displaying a fake zero', () => {\n+    const { rerender } = render(<NotificationBell unreadCount={0} countLoaded showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+    rerender(<NotificationBell unreadCount={4} countLoaded={false} showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+  })\n+\n+  it('keeps the original uncounted bell on other routes sharing the title bar', () => {\n+    render(<NotificationBell unreadCount={8} countLoaded onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+  })\n+\n+  it('caps only the visual number at 99+ while preserving the exact count for accessibility', () => {\n+    render(<NotificationBell unreadCount={123} countLoaded showBadge onClick={() => {}} />)\n+    expect(screen.getByRole('button', { name: '通知，123 条未读' }).textContent).toBe('99+')\n+  })\n+})\ndiff --git a/src/components/mobile/NotificationBell.tsx b/src/components/mobile/NotificationBell.tsx\nnew file mode 100644\nindex 0000000..bd9758e\n--- /dev/null\n+++ b/src/components/mobile/NotificationBell.tsx\n@@ -0,0 +1,32 @@\n+import { Bell } from 'lucide-react'\n+\n+interface NotificationBellProps {\n+  unreadCount: number\n+  countLoaded: boolean\n+  onClick: () => void\n+  showBadge?: boolean\n+}\n+\n+/** A single notice badge for the shared title bar; the API store owns the count. */\n+export default function NotificationBell({ unreadCount, countLoaded, onClick, showBadge = false }: NotificationBellProps) {\n+  const hasUnread = showBadge && countLoaded && unreadCount > 0\n+\n+  return (\n+    <button\n+      type=\"button\"\n+      aria-label={hasUnread ? `通知，${unreadCount} 条未读` : '通知'}\n+      onClick={onClick}\n+      className=\"relative flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\"\n+    >\n+      <Bell className=\"h-[22px] w-[22px]\" aria-hidden />\n+      {hasUnread && (\n+        <span\n+          aria-hidden=\"true\"\n+          className=\"absolute right-0 top-0 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[#EF4444] px-[3px] text-[10px] font-semibold leading-none tabular-nums text-white\"\n+        >\n+          {unreadCount > 99 ? '99+' : unreadCount}\n+        </span>\n+      )}\n+    </button>\n+  )\n+}\ndiff --git a/src/layouts/MobileLayout.notifications.test.tsx b/src/layouts/MobileLayout.notifications.test.tsx\nnew file mode 100644\nindex 0000000..7078b49\n--- /dev/null\n+++ b/src/layouts/MobileLayout.notifications.test.tsx\n@@ -0,0 +1,79 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { act, cleanup, render, screen } from '@testing-library/react'\n+import { createMemoryRouter, RouterProvider } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+\n+const mocks = vi.hoisted(() => ({\n+  refresh: vi.fn().mockResolvedValue(undefined),\n+  setAuthFlowEnabled: vi.fn(),\n+  unreadCount: 6,\n+  countLoaded: true,\n+}))\n+\n+vi.mock('../app/state/notifications', () => ({\n+  useNotifications: () => ({ unreadCount: mocks.unreadCount, countLoaded: mocks.countLoaded }),\n+  refreshRemoteUnreadCount: mocks.refresh,\n+}))\n+vi.mock('../components/mobile/TitleBar', () => ({\n+  default: ({ action }: { action: ReactNode }) => <header>{action}</header>,\n+}))\n+vi.mock('../components/mobile/BottomNav', () => ({ default: () => null }))\n+vi.mock('../components/mobile/H5ScrollRestoration', () => ({ default: () => null }))\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  protectedFixtureRedirect: () => null,\n+  useOverlay: () => ({ open: vi.fn() }),\n+}))\n+vi.mock('../services/auth/session', () => ({\n+  AUTH_EXPIRED_EVENT: 'test:expired',\n+  AUTH_FAILURE_EVENT: 'test:failed',\n+  setAuthFlowEnabled: mocks.setAuthFlowEnabled,\n+}))\n+vi.mock('../services/nativeBridge', () => ({ closeWebView: vi.fn() }))\n+\n+import MobileLayout from './MobileLayout'\n+\n+function showShell(initialPath: string) {\n+  const router = createMemoryRouter([\n+    {\n+      path: '/',\n+      element: <MobileLayout />,\n+      children: [\n+        { path: 'profile', element: <p>会员首页</p> },\n+        { path: 'points', element: <p>泡泡值</p> },\n+        { path: 'dearseed', element: <p>诗得丽</p> },\n+        { path: 'notifications', element: <p>消息列表</p> },\n+      ],\n+    },\n+  ], { initialEntries: [initialPath] })\n+  render(<RouterProvider router={router} />)\n+  return router\n+}\n+\n+afterEach(() => {\n+  cleanup()\n+  mocks.refresh.mockClear()\n+  mocks.setAuthFlowEnabled.mockClear()\n+  mocks.unreadCount = 6\n+  mocks.countLoaded = true\n+})\n+\n+describe('member-center unread badge lifecycle', () => {\n+  it('shows the shared notice count on the profile header and refreshes once on return to the tab', async () => {\n+    const router = showShell('/profile')\n+    expect(screen.getByRole('button', { name: '通知，6 条未读' }).textContent).toBe('6')\n+    // useNotifications already performs its initial request; do not double-query on mount.\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+\n+    await act(async () => { await router.navigate('/dearseed') })\n+    expect(screen.getByRole('button', { name: '通知' }).textContent).toBe('')\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+    await act(async () => { await router.navigate('/points') })\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+    await act(async () => { await router.navigate('/profile') })\n+    expect(mocks.refresh).toHaveBeenCalledTimes(1)\n+\n+    await act(async () => { await router.navigate('/notifications') })\n+    await act(async () => { await router.navigate('/profile') })\n+    expect(mocks.refresh).toHaveBeenCalledTimes(2)\n+  })\n+})\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 6e68042..536a053 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -7,13 +7,14 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, Settings, UserRound } from 'lucide-react'\n+import { Settings, UserRound } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n+import NotificationBell from '../components/mobile/NotificationBell'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n import { getRouteScope, isActiveFormalH5Route, isFormalH5TabPath } from '../app/router/routeScope'\n import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n-import { useNotifications } from '../app/state/notifications'\n+import { refreshRemoteUnreadCount, useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n import Toast from '../components/ui/Toast'\n@@ -110,6 +111,7 @@ export default function MobileLayout() {\n   const location = useLocation()\n   const navigate = useNavigate()\n   const previousShellPathname = useRef(location.pathname)\n+  const previousNoticePathname = useRef(location.pathname)\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n@@ -164,6 +166,14 @@ export default function MobileLayout() {\n   const isNotificationsPage = location.pathname === '/notifications'\n   const allNotificationsRead = countLoaded && unreadCount === 0\n \n+  // The shared notice hook fetches on initial mount/focus. Refresh only when returning\n+  // to the member-center tab, avoiding a duplicate request on the first render.\n+  useEffect(() => {\n+    const enteringProfile = previousNoticePathname.current !== '/profile' && location.pathname === '/profile'\n+    previousNoticePathname.current = location.pathname\n+    if (enteringProfile) void refreshRemoteUnreadCount()\n+  }, [location.pathname])\n+\n   useLayoutEffect(() => {\n     previousShellPathname.current = location.pathname\n   }, [location.pathname])\n@@ -216,9 +226,12 @@ export default function MobileLayout() {\n       )\n       : route?.titleBarAction === 'notifications'\n         ? (\n-          <button type=\"button\" aria-label=\"通知\" onClick={() => navigateShell('/notifications')} className=\"flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\">\n-            <Bell className=\"h-[22px] w-[22px]\" />\n-          </button>\n+          <NotificationBell\n+            unreadCount={unreadCount}\n+            countLoaded={countLoaded}\n+            showBadge={location.pathname === '/profile'}\n+            onClick={() => navigateShell('/notifications')}\n+          />\n         )\n         : undefined",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/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": 55,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:27.039Z",
                  "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": 1134,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:27.036Z",
                  "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": 1083,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:27.138Z",
                  "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": 947,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:28.518Z",
                  "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": 960,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:28.564Z",
                  "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": 884,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:29.478Z",
                  "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": 927,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T09:38:29.540Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-19T09:38:26.145Z",
    "duration": 4414.066,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}