Преглед на файлове

feat(frontend): implement P0 patient service portal

wangkangyjy преди 23 часа
родител
ревизия
0d6effcc38
променени са 40 файла, в които са добавени 2476 реда и са изтрити 47 реда
  1. BIN
      artifacts/frontend-qa/comparison-composer-v2.png
  2. BIN
      artifacts/frontend-qa/comparison-composer.png
  3. BIN
      artifacts/frontend-qa/comparison-services-v2.png
  4. BIN
      artifacts/frontend-qa/comparison-services.png
  5. BIN
      artifacts/frontend-qa/formal-composer-v2.png
  6. BIN
      artifacts/frontend-qa/formal-composer.png
  7. BIN
      artifacts/frontend-qa/formal-registration-full-v2.png
  8. BIN
      artifacts/frontend-qa/formal-registration-full.png
  9. BIN
      artifacts/frontend-qa/formal-services-full-v2.png
  10. BIN
      artifacts/frontend-qa/formal-services-full.png
  11. BIN
      artifacts/frontend-qa/formal-services-v2.png
  12. BIN
      artifacts/frontend-qa/formal-services.png
  13. BIN
      artifacts/frontend-qa/source-composer.png
  14. BIN
      artifacts/frontend-qa/source-services.png
  15. 74 0
      design-qa.md
  16. 1 1
      index.html
  17. 7 2
      package.json
  18. 4 4
      playwright.config.ts
  19. 28 0
      pnpm-lock.yaml
  20. 2 2
      src/App.vue
  21. 4 0
      src/main.ts
  22. 26 0
      src/portal/PortalApp.vue
  23. 156 0
      src/portal/catalog.ts
  24. 329 0
      src/portal/components/AgentConversation.vue
  25. 7 0
      src/portal/components/AssistantMessage.vue
  26. 29 0
      src/portal/components/BottomNavigation.vue
  27. 9 0
      src/portal/components/PortalIcon.vue
  28. 10 0
      src/portal/components/UserMessage.vue
  29. 93 0
      src/portal/pages/HomePage.vue
  30. 32 0
      src/portal/pages/MePage.vue
  31. 38 0
      src/portal/pages/RecordsPage.vue
  32. 97 0
      src/portal/pages/ServicesPage.vue
  33. 1404 0
      src/portal/portal.css
  34. 21 0
      src/portal/state/portalStore.ts
  35. 21 0
      src/portal/types.ts
  36. 12 21
      tests/e2e/registration-flow.spec.ts
  37. 19 8
      tests/e2e/visual-layout.spec.ts
  38. 15 9
      tests/unit/cardRenderer.spec.ts
  39. 29 0
      tests/unit/portalStore.spec.ts
  40. 9 0
      vitest.config.ts

BIN
artifacts/frontend-qa/comparison-composer-v2.png


BIN
artifacts/frontend-qa/comparison-composer.png


BIN
artifacts/frontend-qa/comparison-services-v2.png


BIN
artifacts/frontend-qa/comparison-services.png


BIN
artifacts/frontend-qa/formal-composer-v2.png


BIN
artifacts/frontend-qa/formal-composer.png


BIN
artifacts/frontend-qa/formal-registration-full-v2.png


BIN
artifacts/frontend-qa/formal-registration-full.png


BIN
artifacts/frontend-qa/formal-services-full-v2.png


BIN
artifacts/frontend-qa/formal-services-full.png


BIN
artifacts/frontend-qa/formal-services-v2.png


BIN
artifacts/frontend-qa/formal-services.png


BIN
artifacts/frontend-qa/source-composer.png


BIN
artifacts/frontend-qa/source-services.png


+ 74 - 0
design-qa.md

@@ -0,0 +1,74 @@
+# Design QA — 医梦患者智能服务门户正式前端
+
+## Comparison target
+
+- Source visual truth:
+  - `prototype/adjutant-mobile/qa/implementation-services.png`
+  - `prototype/adjutant-mobile/qa/implementation-composer.png`
+- Formal Vue implementation:
+  - `artifacts/frontend-qa/formal-services-v2.png`
+  - `artifacts/frontend-qa/formal-composer-v2.png`
+- Combined evidence:
+  - `artifacts/frontend-qa/comparison-services-v2.png`
+  - `artifacts/frontend-qa/comparison-composer-v2.png`
+- Browser viewport: `1280 × 720`
+- Source and implementation mobile content viewport: normalized to `430 × 720` at 1× density
+- Compared states:
+  - 服务目录:门诊服务 / 诊前
+  - 挂号会话:医生补槽后的默认回答与输入区
+
+## Full-view comparison
+
+- Formal Vue service catalog preserves the prototype hierarchy: title, search, first-level service cluster, second-level stage, agent list, disabled state, and bottom navigation.
+- The three P0 agents retain their original semantic colors and enabled labels.
+- Disabled agents remain visible but cannot be invoked.
+- Desktop preview only adds a neutral outside canvas; the product itself remains a `430px` maximum-width mobile Web viewport.
+
+## Focused-region comparison
+
+- The formal conversation composer reproduces the prototype's single recommended-answer row.
+- Voice, text, and camera/send controls retain the same order, radius, spacing, helper text, and interaction semantics.
+- Focused comparison was required because these controls are too small for reliable judgment in the full-page view.
+
+## Required fidelity surfaces
+
+- Typography: hierarchy, Chinese font fallbacks, weights, line heights, truncation, and small helper copy match the prototype.
+- Spacing and layout: final top spacing, horizontal tabs, card rhythm, bottom navigation, and composer dimensions match after normalization.
+- Colors and tokens:医梦 purple, cyan, neutral disabled state, success, and risk colors map directly to the prototype tokens.
+- Image and icon quality: the formal app uses the bundled Radix icon collection through Iconify; no placeholder glyphs or handcrafted SVG assets were introduced.
+- Copy and content: hospital name, P0 labels, patient status, agent names, Mock notices, recommended answers, and structured medical result copy match the approved prototype.
+
+## Comparison history
+
+### Pass 1
+
+- [P2] Formal page content started about 50 px higher than the approved prototype.
+  - Fix: aligned root-page top padding to the source viewport.
+- [P2] Automated pointer interaction left an overly strong focus ring on the persistent bottom navigation and recommended-answer control.
+  - Fix: removed the mismatched focus outline from those two pointer-oriented controls while preserving input focus styling.
+
+### Pass 2
+
+- Revised side-by-side captures show matching hierarchy, density, spacing, colors, and component proportions.
+- No actionable P0, P1, or P2 visual differences remain.
+
+## Interaction and engineering checks
+
+- Home, service catalog, health records, and profile navigation work.
+- Service cluster and stage switching work.
+- Search filters the current agent list.
+- Only the three P0 agents are enabled.
+- Registration completes voice-style slot filling, Mock doctor selection, Mock slot selection, confirmation, success, and emergency diversion.
+- Report interpretation completes upload, processing, structured result, risk reminder, and follow-up.
+- Tongue assessment completes constitution questions, image selection, MCP-style processing, structured result, and follow-up.
+- Voice shortcut, text submission, camera/file selection, and recommended answers are interactive.
+- Browser console warnings/errors checked: none.
+- Unit tests: `27 passed`.
+- End-to-end tests: `6 passed` across `390 × 844` mobile Web and `1280 × 720` desktop preview.
+- Type check and production build passed.
+
+## Follow-up polish
+
+- [P3] Replace the generic hospital mark with the target hospital's official logo when the deployment hospital is confirmed.
+
+final result: passed

+ 1 - 1
index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>医梦门诊助手</title>
+    <title>医梦患者智能服务门户</title>
   </head>
   <body>
     <div id="app"></div>

+ 7 - 2
package.json

@@ -1,5 +1,5 @@
 {
-  "name": "emoon-terminal-client",
+  "name": "emoon-patient-service-portal",
   "version": "0.1.0",
   "private": true,
   "type": "module",
@@ -12,6 +12,8 @@
     "test": "pnpm test:unit && pnpm test:e2e"
   },
   "dependencies": {
+    "@iconify-json/radix-icons": "^1.2.6",
+    "@iconify/vue": "^5.0.1",
     "@vitejs/plugin-vue": "^5.2.0",
     "pinia": "^2.3.1",
     "vue": "^3.5.13"
@@ -27,6 +29,9 @@
     "vue-tsc": "^2.2.0"
   },
   "pnpm": {
-    "onlyBuiltDependencies": ["esbuild", "vue-demi"]
+    "onlyBuiltDependencies": [
+      "esbuild",
+      "vue-demi"
+    ]
   }
 }

+ 4 - 4
playwright.config.ts

@@ -13,12 +13,12 @@ export default defineConfig({
   },
   projects: [
     {
-      name: 'desktop-1440',
-      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 1440, height: 900 } }
+      name: 'mobile-web-390',
+      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 390, height: 844 } }
     },
     {
-      name: 'desktop-1920',
-      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 1920, height: 1080 } }
+      name: 'desktop-preview-1280',
+      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 1280, height: 720 } }
     }
   ]
 });

+ 28 - 0
pnpm-lock.yaml

@@ -8,6 +8,12 @@ importers:
 
   .:
     dependencies:
+      '@iconify-json/radix-icons':
+        specifier: ^1.2.6
+        version: 1.2.6
+      '@iconify/vue':
+        specifier: ^5.0.1
+        version: 5.0.1(vue@3.5.35(typescript@5.9.3))
       '@vitejs/plugin-vue':
         specifier: ^5.2.0
         version: 5.2.4(vite@6.4.3(@types/node@22.19.19))(vue@3.5.35(typescript@5.9.3))
@@ -387,6 +393,17 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@iconify-json/radix-icons@1.2.6':
+    resolution: {integrity: sha512-izgVBJwk37dSvsma1QsRMfCWQ4FIImbBmJId1WUURzRZA5fFyKlCcG/nZC8se0KhOBWszo2mYgLmhKJv/DFPoA==}
+
+  '@iconify/types@2.0.0':
+    resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+  '@iconify/vue@5.0.1':
+    resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==}
+    peerDependencies:
+      vue: '>=3.0.0'
+
   '@isaacs/cliui@8.0.2':
     resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
     engines: {node: '>=12'}
@@ -1537,6 +1554,17 @@ snapshots:
   '@esbuild/win32-x64@0.25.12':
     optional: true
 
+  '@iconify-json/radix-icons@1.2.6':
+    dependencies:
+      '@iconify/types': 2.0.0
+
+  '@iconify/types@2.0.0': {}
+
+  '@iconify/vue@5.0.1(vue@3.5.35(typescript@5.9.3))':
+    dependencies:
+      '@iconify/types': 2.0.0
+      vue: 3.5.35(typescript@5.9.3)
+
   '@isaacs/cliui@8.0.2':
     dependencies:
       string-width: 5.1.2

+ 2 - 2
src/App.vue

@@ -1,7 +1,7 @@
 <template>
-  <AppShell />
+  <PortalApp />
 </template>
 
 <script setup lang="ts">
-import AppShell from './layouts/AppShell.vue';
+import PortalApp from './portal/PortalApp.vue';
 </script>

+ 4 - 0
src/main.ts

@@ -1,6 +1,10 @@
 import { createPinia } from 'pinia';
 import { createApp } from 'vue';
+import { addCollection } from '@iconify/vue';
+import radixIcons from '@iconify-json/radix-icons/icons.json';
 import App from './App.vue';
 import './theme/tokens.css';
+import './portal/portal.css';
 
+addCollection(radixIcons);
 createApp(App).use(createPinia()).mount('#app');

+ 26 - 0
src/portal/PortalApp.vue

@@ -0,0 +1,26 @@
+<template>
+  <div class="portal-stage">
+    <div class="portal-viewport">
+      <template v-if="!store.activeAgent">
+        <HomePage v-if="store.activeTab === 'home'" @open-agent="store.openAgent" @open-tab="store.switchTab" />
+        <ServicesPage v-else-if="store.activeTab === 'services'" @open-agent="store.openAgent" />
+        <RecordsPage v-else-if="store.activeTab === 'records'" @open-agent="store.openAgent" />
+        <MePage v-else />
+        <BottomNavigation :active="store.activeTab" @select="store.switchTab" />
+      </template>
+      <AgentConversation v-else :mode="store.activeAgent" @close="store.closeAgent" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import AgentConversation from './components/AgentConversation.vue';
+import BottomNavigation from './components/BottomNavigation.vue';
+import HomePage from './pages/HomePage.vue';
+import MePage from './pages/MePage.vue';
+import RecordsPage from './pages/RecordsPage.vue';
+import ServicesPage from './pages/ServicesPage.vue';
+import { usePortalStore } from './state/portalStore';
+
+const store = usePortalStore();
+</script>

+ 156 - 0
src/portal/catalog.ts

@@ -0,0 +1,156 @@
+import type { CatalogCluster } from './types';
+
+export const agentCatalog: CatalogCluster[] = [
+  {
+    id: 'outpatient',
+    label: '门诊服务',
+    stages: [
+      {
+        id: 'before',
+        label: '诊前',
+        agents: [
+          { id: 'outpatient-guide', title: '智能导诊', description: '院内服务与就医流程引导' },
+          { id: 'outpatient-triage', title: '智能分诊', description: '根据症状推荐合适科室' },
+          {
+            id: 'smart-registration',
+            title: '智能分诊挂号',
+            description: '语音补槽、Mock 号源与预约确认',
+            mode: 'registration',
+          },
+          { id: 'outpatient-checkin', title: '门诊签到叫号', description: '到院签到与候诊进度提醒' },
+        ],
+      },
+      {
+        id: 'during',
+        label: '诊中',
+        agents: [
+          {
+            id: 'tcm-tongue-assessment',
+            title: '中医舌诊',
+            description: '体质问答、舌象采集与综合建议',
+            mode: 'tongue',
+          },
+          { id: 'outpatient-cds', title: '临床决策支持', description: '为诊疗过程提供知识辅助' },
+          { id: 'outpatient-exam-booking', title: '检验检查预约', description: '检查项目预约与注意事项' },
+          {
+            id: 'report-interpretation',
+            title: '检验检查报告解读',
+            description: '上传报告、结构化解读与风险提醒',
+            mode: 'report',
+          },
+          { id: 'outpatient-payment', title: '门诊缴费', description: '门诊费用查询与缴费引导' },
+          { id: 'outpatient-record-writing', title: '病历书写', description: '病历信息整理与结构化生成' },
+          { id: 'outpatient-record-quality', title: '病历质控', description: '门诊病历完整性与规范检查' },
+          { id: 'outpatient-self-service', title: '自助门诊场景', description: '常见门诊服务自助办理' },
+        ],
+      },
+      {
+        id: 'after',
+        label: '诊后',
+        agents: [
+          { id: 'outpatient-followup', title: '智能随访', description: '诊后情况采集与随访提醒' },
+          { id: 'outpatient-rehab', title: '康复计划生成', description: '个体化康复任务与计划' },
+          { id: 'outpatient-medication', title: '智能用药提醒', description: '按医嘱提供用药时间提醒' },
+        ],
+      },
+    ],
+  },
+  {
+    id: 'inpatient',
+    label: '住院服务',
+    stages: [
+      {
+        id: 'admission',
+        label: '入院前',
+        agents: [
+          { id: 'pre-admission-assessment', title: '智能预住院评估', description: '入院前资料与风险信息采集' },
+          { id: 'admission-booking', title: '智能预约与入院办理', description: '预约床位与入院流程引导' },
+          { id: 'inpatient-deposit', title: '住院费预交', description: '预交金查询与缴纳引导' },
+        ],
+      },
+      {
+        id: 'ward',
+        label: '住院中',
+        agents: [
+          { id: 'ward-education', title: '入院宣教 / 入科接待', description: '入科须知和病区服务介绍' },
+          { id: 'inpatient-records', title: '智慧病历管理', description: '住院病历信息整理与管理' },
+          { id: 'inpatient-cds', title: '临床决策支持', description: '住院诊疗知识辅助' },
+          { id: 'inpatient-report', title: '检验检查报告解读', description: '住院报告结构化解读' },
+          { id: 'ward-iot', title: 'AI 病区物联网', description: '病区设备与状态智能协同' },
+          { id: 'smart-nursing', title: '智慧护理', description: '护理任务与患者服务协同' },
+          { id: 'discharge-planning', title: '智能出院准备与规划', description: '出院条件与后续事项准备' },
+        ],
+      },
+      {
+        id: 'discharge',
+        label: '出院后',
+        agents: [
+          { id: 'bedside-payment', title: '床旁结算', description: '出院费用与床旁结算引导' },
+          { id: 'remote-management', title: '远程管理', description: '出院后健康状态远程管理' },
+          { id: 'inpatient-followup', title: '智能随访', description: '出院后恢复情况随访' },
+          { id: 'inpatient-rehab', title: '康复计划生成', description: '院外康复任务规划' },
+          { id: 'inpatient-medication', title: '智能用药提醒', description: '出院用药提醒与反馈' },
+        ],
+      },
+    ],
+  },
+  {
+    id: 'checkup',
+    label: '体检服务',
+    stages: [
+      {
+        id: 'checkup-before',
+        label: '检前',
+        agents: [
+          { id: 'checkup-questionnaire', title: '调查问卷', description: '体检前健康信息采集' },
+          { id: 'checkup-plan', title: '方案设计', description: '个体化体检方案建议' },
+          { id: 'checkup-booking', title: '预约体检', description: '体检时间与套餐预约' },
+          { id: 'checkup-education', title: '检前宣教', description: '体检准备事项提醒' },
+        ],
+      },
+      {
+        id: 'checkup-during',
+        label: '检中',
+        agents: [
+          { id: 'checkup-checkin', title: '智能签到', description: '到院签到与身份确认' },
+          { id: 'checkup-guide', title: '智能导检', description: '检查顺序与位置引导' },
+          { id: 'fasting-priority', title: '空腹项目优先', description: '智能安排空腹检查项目' },
+          { id: 'department-check', title: '科室检查', description: '检查科室流程协同' },
+          { id: 'sample-collection', title: '标本采集', description: '采集项目与注意事项提示' },
+          { id: 'nutrition-breakfast', title: '营养早餐', description: '空腹项目结束后的营养服务' },
+        ],
+      },
+      {
+        id: 'checkup-after',
+        label: '检后',
+        agents: [
+          { id: 'checkup-review', title: '智能辅助总检审核', description: '体检结论辅助审核' },
+          { id: 'checkup-report', title: '报告生成', description: '体检结果汇总与报告生成' },
+          { id: 'checkup-interpretation', title: '报告解读', description: '体检报告健康解读' },
+          { id: 'green-channel', title: '绿色通道', description: '异常结果进一步就医协助' },
+          { id: 'checkup-record', title: '档案建立', description: '院内体检健康记录归档' },
+          { id: 'checkup-followup', title: '定期随访', description: '体检后定期健康随访' },
+        ],
+      },
+    ],
+  },
+  {
+    id: 'bi',
+    label: '运营管理',
+    stages: [
+      {
+        id: 'bi-all',
+        label: '全部',
+        agents: [
+          { id: 'bi-global', title: '全局运营数据问答', description: '医院运营指标查询与解释' },
+          { id: 'bi-forecast', title: '运营预测', description: '运营趋势与资源需求预测' },
+          { id: 'bi-finance', title: '财务管控', description: '财务指标分析与风险提示' },
+          { id: 'bi-material', title: '物资管理', description: '库存、消耗和补给管理' },
+          { id: 'bi-quality', title: '质量管理', description: '医疗质量指标监测' },
+          { id: 'bi-hr', title: '人力绩效', description: '人员与绩效数据分析' },
+          { id: 'bi-device', title: '设备运维', description: '设备状态与运维任务管理' },
+        ],
+      },
+    ],
+  },
+];

+ 329 - 0
src/portal/components/AgentConversation.vue

@@ -0,0 +1,329 @@
+<template>
+  <section class="agent-conversation-shell">
+    <header class="task-header">
+      <button type="button" aria-label="返回" @click="$emit('close')"><PortalIcon name="arrow-left" /></button>
+      <strong>{{ agentTitle }}</strong>
+      <span />
+    </header>
+
+    <main class="agent-conversation" :class="mode">
+      <section class="agent-session-intro">
+        <span :class="['service-icon', mode]"><PortalIcon :name="agentIcon" /></span>
+        <div><strong>{{ agentTitle }}</strong><small>本次服务独立保存 · 演示数据为 Mock</small></div>
+      </section>
+
+      <template v-if="mode === 'registration'">
+        <AssistantMessage>
+          您好,我会通过连续追问收集挂号所需信息。您可以直接用一句话说出症状、科室、日期、时间和号别。
+        </AssistantMessage>
+
+        <section v-if="registrationPhase === 'start'" class="conversation-demo-prompt">
+          <strong>推荐演示语句</strong>
+          <p>“我头疼,帮我挂神经内科明天早上九点的专家号。”</p>
+          <button type="button" @click="useDefaultAnswer"><PortalIcon name="speaker-loud" />点击模拟语音输入</button>
+          <button class="danger-action" type="button" @click="sendRegistration('我胸痛,还呼吸困难')">演示急诊风险识别</button>
+        </section>
+
+        <template v-if="registrationPhase !== 'start' && registrationPhase !== 'emergency'">
+          <UserMessage label="语音已转文字">我头疼,帮我挂神经内科明天早上九点的专家号</UserMessage>
+        </template>
+
+        <template v-if="registrationPhase === 'doctor'">
+          <AssistantMessage>
+            <p>我已记录:头疼、神经内科、明天上午 09:00、专家号。当前未发现明确急诊红旗。</p>
+            <p><strong>还差最后一项:您想挂哪位医生?</strong></p>
+          </AssistantMessage>
+          <div class="mcp-return-note"><span>Mock MCP 返回</span><strong>李明主任、王佳副主任、周海主任</strong></div>
+        </template>
+
+        <template v-if="['slots', 'confirm', 'done'].includes(registrationPhase)">
+          <UserMessage>李明医生</UserMessage>
+          <AssistantMessage>信息已经收集完整,正在查询符合条件的 Mock 号源。</AssistantMessage>
+        </template>
+
+        <section v-if="registrationPhase === 'slots'" class="slot-card">
+          <div><strong>可预约号源</strong><span>Mock HIS</span></div>
+          <p>神经内科 · 李明 主任医师 · 明天上午</p>
+          <button type="button" @click="registrationPhase = 'confirm'">
+            <span><strong>09:00</strong><small>专家号 · ¥25 · 剩余 2</small></span><em>选择</em>
+          </button>
+          <button type="button" @click="registrationPhase = 'confirm'">
+            <span><strong>09:30</strong><small>专家号 · ¥25 · 剩余 1</small></span><em>选择</em>
+          </button>
+        </section>
+
+        <template v-if="registrationPhase === 'confirm'">
+          <UserMessage>选择明天 09:00</UserMessage>
+          <AssistantMessage>
+            <div class="structured-result compact">
+              <h3>请确认挂号信息</h3>
+              <ul>
+                <li>就诊人:王*明</li><li>科室:神经内科</li><li>医生:李明 主任医师</li>
+                <li>时间:明天 09:00</li><li>费用:¥25(Mock)</li>
+              </ul>
+            </div>
+          </AssistantMessage>
+          <button class="conversation-confirm" type="button" @click="registrationPhase = 'done'">确认 Mock 挂号</button>
+        </template>
+
+        <template v-if="registrationPhase === 'done'">
+          <UserMessage>确认挂号</UserMessage>
+          <AssistantMessage tone="success">
+            <div class="structured-result compact">
+              <h3>挂号成功(Mock)</h3>
+              <p>明天 09:00 · 神经内科 · 李明 主任医师</p>
+              <p>请提前 15 分钟到院,并携带有效就诊凭证。</p>
+            </div>
+          </AssistantMessage>
+        </template>
+
+        <template v-if="registrationPhase === 'emergency'">
+          <UserMessage label="语音已转文字">我胸痛,还呼吸困难,想挂明天心内科。</UserMessage>
+          <AssistantMessage tone="danger">
+            <div class="structured-result compact">
+              <h3>请立即寻求紧急医疗帮助</h3>
+              <p>“胸痛 + 呼吸困难”可能存在紧急风险,已停止普通挂号流程。</p>
+              <p>请立即拨打 120 或前往最近的急诊。如身边有人,请让其陪同。</p>
+            </div>
+          </AssistantMessage>
+        </template>
+
+        <template v-if="followupQuestion && registrationPhase === 'done'">
+          <UserMessage>{{ followupQuestion }}</UserMessage>
+          <AssistantMessage>建议提前 15 分钟到院,携带有效就诊凭证和既往检查资料;如症状明显加重,请及时转急诊。</AssistantMessage>
+        </template>
+      </template>
+
+      <template v-else-if="mode === 'report'">
+        <AssistantMessage>
+          请在本次对话中上传清晰的检验或检查报告。识别后,我会按“风险提醒、异常指标、联合解读和行动建议”整理结果。
+        </AssistantMessage>
+
+        <button v-if="reportPhase === 'start'" class="conversation-upload" type="button" @click="beginReportUpload">
+          <PortalIcon name="upload" /><span><strong>上传演示血常规</strong><small>支持图片或 PDF · 点击体验</small></span>
+        </button>
+        <UserMessage v-else label="已上传文件"><span class="message-file"><PortalIcon name="file-text" />血常规_2026-07-27.jpg</span></UserMessage>
+
+        <AssistantMessage v-if="reportPhase === 'processing'">
+          <div class="conversation-processing"><span /><div><strong>正在识别和解读报告</strong><small>正在校验患者信息、指标名称、参考范围和风险项</small></div></div>
+          <button class="inline-demo-action" type="button" @click="reportPhase = 'result'">完成演示分析</button>
+        </AssistantMessage>
+
+        <AssistantMessage v-if="reportPhase === 'result'" tone="result">
+          <div class="structured-result">
+            <h2>血常规解读</h2>
+            <blockquote><strong>风险提醒:</strong>本演示未触发危急值,但血小板偏低。如伴随明显出血、黑便或持续头晕,请及时就医。</blockquote>
+            <h3>报告概览</h3>
+            <p>共识别 22 项指标,其中 <strong>3 项异常</strong>,主要集中在血小板相关指标。</p>
+            <h3>异常指标</h3>
+            <div class="result-table">
+              <div><strong>指标</strong><strong>结果</strong><strong>状态</strong></div>
+              <div><span>血小板 PLT</span><span>78 ×10⁹/L</span><em>偏低</em></div>
+              <div><span>平均血小板体积</span><span>12.8 fL</span><em>偏高</em></div>
+              <div><span>血小板分布宽度</span><span>17.2%</span><em>偏高</em></div>
+            </div>
+            <h3>联合解读</h3>
+            <p>血小板计数降低,同时平均血小板体积升高。需要结合近期感染、用药、出血表现和既往趋势进一步判断。</p>
+            <h3>建议下一步</h3>
+            <ol>
+              <li>携带本次及既往报告咨询原就诊科室或血液科。</li>
+              <li>如近期服用影响凝血的药物,请主动告知医生。</li>
+              <li>正式判断以医院医生意见为准。</li>
+            </ol>
+          </div>
+        </AssistantMessage>
+
+        <template v-if="followupQuestion && reportPhase === 'result'">
+          <UserMessage>{{ followupQuestion }}</UserMessage>
+          <AssistantMessage>血小板偏低表示参与止血的血小板数量少于本报告参考范围。单次结果不能直接判断病因,需要结合出血表现、近期感染、用药和复查趋势由医生综合评估。</AssistantMessage>
+        </template>
+      </template>
+
+      <template v-else>
+        <AssistantMessage>
+          我会先了解您近期的身体感受,再引导拍摄舌象。分析结果用于健康参考,不能替代中医师完整辨证。
+        </AssistantMessage>
+
+        <template v-if="tonguePhase === 'question-one'">
+          <AssistantMessage><strong>第 1 题:</strong>最近最明显的不适是什么?</AssistantMessage>
+        </template>
+        <UserMessage v-if="tonguePhase !== 'question-one'">我最近容易疲倦,饭后有些腹胀</UserMessage>
+
+        <template v-if="tonguePhase === 'question-two'">
+          <AssistantMessage><strong>第 2 题:</strong>平时更容易怕冷还是怕热?</AssistantMessage>
+        </template>
+
+        <template v-if="['upload', 'processing', 'result'].includes(tonguePhase)">
+          <UserMessage>我平时更容易怕冷</UserMessage>
+          <AssistantMessage>问答已完成。请在自然光下伸出舌体,避免滤镜,确保舌尖和舌根完整入镜。</AssistantMessage>
+        </template>
+
+        <button v-if="tonguePhase === 'upload'" class="conversation-upload tongue-upload" type="button" @click="beginTongueUpload">
+          <PortalIcon name="camera" /><span><strong>拍摄并上传演示舌象</strong><small>正面 · 清晰 · 自然光</small></span>
+        </button>
+
+        <UserMessage v-if="['processing', 'result'].includes(tonguePhase)" label="已上传图片">
+          <span class="message-file"><PortalIcon name="camera" />舌象照片.jpg</span>
+        </UserMessage>
+
+        <AssistantMessage v-if="tonguePhase === 'processing'">
+          <div class="conversation-processing"><span /><div><strong>正在分析舌象</strong><small>正在调用医院已接入的舌象分析服务并融合问答信息</small></div></div>
+          <button class="inline-demo-action" type="button" @click="tonguePhase = 'result'">返回演示结果</button>
+        </AssistantMessage>
+
+        <AssistantMessage v-if="tonguePhase === 'result'" tone="result">
+          <div class="structured-result">
+            <h2>中医舌诊综合结果</h2>
+            <blockquote class="positive"><strong>综合倾向:</strong>气虚体质,兼有脾气虚表现(仅供健康参考)</blockquote>
+            <h3>问答信息</h3><ul><li>容易疲倦、饭后腹胀</li><li>平时较怕冷</li></ul>
+            <h3>舌象特征</h3><ul><li>舌色:淡红</li><li>舌体:偏胖,可见齿痕</li><li>舌苔:薄白,偏润</li></ul>
+            <h3>综合建议</h3>
+            <ol><li>饮食规律,减少生冷油腻。</li><li>保持适量运动和充足睡眠。</li><li>如疲倦或腹胀持续加重,建议由中医师结合脉诊进一步辨证。</li></ol>
+          </div>
+        </AssistantMessage>
+
+        <template v-if="followupQuestion && tonguePhase === 'result'">
+          <UserMessage>{{ followupQuestion }}</UserMessage>
+          <AssistantMessage>饮食以温热、清淡、规律为主,减少生冷和油腻食物。建议把持续疲倦、腹胀等情况记录下来,必要时由中医师结合脉诊进一步辨证。</AssistantMessage>
+        </template>
+      </template>
+
+      <div class="conversation-end-space" />
+    </main>
+
+    <div class="conversation-input-dock">
+      <button class="default-answer" type="button" @click="useDefaultAnswer">
+        <span>推荐回答</span><strong>{{ defaultAnswer }}</strong><PortalIcon name="chevron-right" />
+      </button>
+      <div class="conversation-composer">
+        <button type="button" class="composer-voice" aria-label="按住说话" @click="useDefaultAnswer"><PortalIcon name="speaker-loud" /></button>
+        <input v-model="draft" :placeholder="composerPlaceholder" aria-label="输入消息" @keydown.enter.prevent="sendDraft" />
+        <button type="button" class="composer-camera" :aria-label="draft ? '发送文字' : '拍照或上传图片'" @click="draft ? sendDraft() : openFilePicker()">
+          <PortalIcon :name="draft ? 'paper-plane' : 'camera'" />
+        </button>
+        <input ref="fileInput" class="visually-hidden" type="file" accept="image/*,.pdf,application/pdf" @change="handleFileSelected" />
+      </div>
+      <small>语音内容会先转为文字,重要信息需确认后执行</small>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { AgentMode } from '../types';
+import PortalIcon from './PortalIcon.vue';
+import AssistantMessage from './AssistantMessage.vue';
+import UserMessage from './UserMessage.vue';
+
+type RegistrationPhase = 'start' | 'doctor' | 'slots' | 'confirm' | 'done' | 'emergency';
+type ReportPhase = 'start' | 'processing' | 'result';
+type TonguePhase = 'question-one' | 'question-two' | 'upload' | 'processing' | 'result';
+
+const props = defineProps<{ mode: AgentMode }>();
+defineEmits<{ close: [] }>();
+
+const draft = ref('');
+const fileInput = ref<HTMLInputElement | null>(null);
+const registrationPhase = ref<RegistrationPhase>('start');
+const reportPhase = ref<ReportPhase>('start');
+const tonguePhase = ref<TonguePhase>('question-one');
+const followupQuestion = ref('');
+
+const agentTitle = computed(() => props.mode === 'registration' ? '智能分诊挂号' : props.mode === 'report' ? '报告智能解读' : '中医舌诊');
+const agentIcon = computed(() => props.mode === 'registration' ? 'calendar' : props.mode === 'report' ? 'file-text' : 'heart');
+const composerPlaceholder = computed(() => {
+  if (props.mode === 'registration') return registrationPhase.value === 'doctor' ? '说出医生姓名,或说“不限医生”' : '继续描述挂号需求';
+  if (props.mode === 'report') return '可继续追问这份报告';
+  return tonguePhase.value === 'question-one' ? '说出近期最明显的不适' : '继续回答问题';
+});
+const defaultAnswer = computed(() => {
+  if (props.mode === 'registration') {
+    if (registrationPhase.value === 'start') return '我头疼,帮我挂神经内科明天上午九点的专家号';
+    if (registrationPhase.value === 'doctor') return '我想挂李明医生';
+    if (registrationPhase.value === 'slots') return '我选择明天上午 09:00 的号源';
+    if (registrationPhase.value === 'confirm') return '确认以上挂号信息';
+    if (registrationPhase.value === 'done') return '请告诉我就诊前需要注意什么';
+    return '请告诉我最近的急诊入口';
+  }
+  if (props.mode === 'report') {
+    if (reportPhase.value === 'start') return '我想上传一份血常规报告';
+    if (reportPhase.value === 'processing') return '请继续分析这份报告';
+    return '请解释血小板偏低是什么意思';
+  }
+  if (tonguePhase.value === 'question-one') return '我最近容易疲倦,饭后有些腹胀';
+  if (tonguePhase.value === 'question-two') return '我平时更容易怕冷';
+  if (tonguePhase.value === 'upload') return '我已准备好拍摄舌象';
+  if (tonguePhase.value === 'processing') return '请继续生成综合结果';
+  return '我在饮食上需要注意什么';
+});
+
+function sendRegistration(value: string) {
+  draft.value = '';
+  followupQuestion.value = '';
+  if (value.includes('胸痛') || value.includes('呼吸困难')) {
+    registrationPhase.value = 'emergency';
+    return;
+  }
+  if (registrationPhase.value === 'start') registrationPhase.value = 'doctor';
+  else if (registrationPhase.value === 'doctor') registrationPhase.value = 'slots';
+  else if (registrationPhase.value === 'done') followupQuestion.value = value;
+}
+
+function sendTongue(value: string) {
+  draft.value = '';
+  followupQuestion.value = '';
+  if (tonguePhase.value === 'question-one') tonguePhase.value = 'question-two';
+  else if (tonguePhase.value === 'question-two') tonguePhase.value = 'upload';
+  else if (tonguePhase.value === 'result') followupQuestion.value = value;
+}
+
+function beginReportUpload() {
+  reportPhase.value = 'processing';
+}
+
+function beginTongueUpload() {
+  tonguePhase.value = 'processing';
+}
+
+function useDefaultAnswer() {
+  const answer = defaultAnswer.value;
+  if (props.mode === 'registration') {
+    if (registrationPhase.value === 'slots') registrationPhase.value = 'confirm';
+    else if (registrationPhase.value === 'confirm') registrationPhase.value = 'done';
+    else if (registrationPhase.value === 'emergency') followupQuestion.value = answer;
+    else sendRegistration(answer);
+    return;
+  }
+  if (props.mode === 'report') {
+    if (reportPhase.value === 'start') beginReportUpload();
+    else if (reportPhase.value === 'processing') reportPhase.value = 'result';
+    else followupQuestion.value = answer;
+    return;
+  }
+  if (tonguePhase.value === 'upload') beginTongueUpload();
+  else if (tonguePhase.value === 'processing') tonguePhase.value = 'result';
+  else sendTongue(answer);
+}
+
+function sendDraft() {
+  const value = draft.value.trim();
+  if (!value) return;
+  if (props.mode === 'registration') sendRegistration(value);
+  else if (props.mode === 'report') {
+    if (reportPhase.value === 'start') beginReportUpload();
+    else if (reportPhase.value === 'result') followupQuestion.value = value;
+    draft.value = '';
+  } else sendTongue(value);
+}
+
+function openFilePicker() {
+  if (props.mode === 'registration') return;
+  fileInput.value?.click();
+}
+
+function handleFileSelected() {
+  if (props.mode === 'report') beginReportUpload();
+  if (props.mode === 'tongue' && tonguePhase.value === 'upload') beginTongueUpload();
+  if (fileInput.value) fileInput.value.value = '';
+}
+</script>

+ 7 - 0
src/portal/components/AssistantMessage.vue

@@ -0,0 +1,7 @@
+<template>
+  <article :class="['conversation-message', 'assistant', tone]"><slot /></article>
+</template>
+
+<script setup lang="ts">
+withDefaults(defineProps<{ tone?: 'default' | 'success' | 'danger' | 'result' }>(), { tone: 'default' });
+</script>

+ 29 - 0
src/portal/components/BottomNavigation.vue

@@ -0,0 +1,29 @@
+<template>
+  <nav class="portal-bottom-nav" aria-label="主导航">
+    <button
+      v-for="item in items"
+      :key="item.id"
+      type="button"
+      :class="{ active: active === item.id }"
+      @click="$emit('select', item.id)"
+    >
+      <PortalIcon :name="item.icon" />
+      <span>{{ item.label }}</span>
+    </button>
+  </nav>
+</template>
+
+<script setup lang="ts">
+import PortalIcon from './PortalIcon.vue';
+import type { RootTab } from '../types';
+
+defineProps<{ active: RootTab }>();
+defineEmits<{ select: [tab: RootTab] }>();
+
+const items: Array<{ id: RootTab; label: string; icon: string }> = [
+  { id: 'home', label: '首页', icon: 'home' },
+  { id: 'services', label: '服务', icon: 'dashboard' },
+  { id: 'records', label: '健康记录', icon: 'reader' },
+  { id: 'me', label: '我的', icon: 'person' },
+];
+</script>

+ 9 - 0
src/portal/components/PortalIcon.vue

@@ -0,0 +1,9 @@
+<template>
+  <Icon :icon="`radix-icons:${name}`" aria-hidden="true" />
+</template>
+
+<script setup lang="ts">
+import { Icon } from '@iconify/vue';
+
+defineProps<{ name: string }>();
+</script>

+ 10 - 0
src/portal/components/UserMessage.vue

@@ -0,0 +1,10 @@
+<template>
+  <article class="conversation-message user">
+    <small v-if="label">{{ label }}</small>
+    <p><slot /></p>
+  </article>
+</template>
+
+<script setup lang="ts">
+defineProps<{ label?: string }>();
+</script>

+ 93 - 0
src/portal/pages/HomePage.vue

@@ -0,0 +1,93 @@
+<template>
+  <main class="portal-page home-page" aria-label="医梦患者智能服务门户首页">
+    <header class="brand-header">
+      <div class="brand-lockup">
+        <span class="brand-mark"><PortalIcon name="plus" /></span>
+        <div>
+          <h1>空海医院</h1>
+          <p>医梦患者智能服务门户</p>
+        </div>
+      </div>
+      <button class="patient-chip" type="button"><PortalIcon name="person" />王*明</button>
+    </header>
+
+    <section class="welcome-block">
+      <h2>今天需要什么帮助?</h2>
+      <p>可以问症状、查报告、找医生、了解健康知识</p>
+      <button class="assistant-entry" type="button" @click="$emit('openAgent', 'registration')">
+        <span><PortalIcon name="speaker-loud" /></span>
+        <strong>按住说话,告诉我想办理什么</strong>
+        <PortalIcon name="upload" />
+      </button>
+    </section>
+
+    <section class="patient-status-card">
+      <div class="section-heading">
+        <h3>患者状态与待办</h3>
+        <span>3 项</span>
+      </div>
+      <button type="button" @click="$emit('openAgent', 'registration')">
+        <span class="status-icon registration"><PortalIcon name="calendar" /></span>
+        <span><strong>明天 09:30 神经内科预约</strong><small>李明 主任医师 · 请提前 15 分钟到院</small></span>
+        <PortalIcon name="chevron-right" />
+      </button>
+      <button type="button" @click="$emit('openAgent', 'report')">
+        <span class="status-icon report"><PortalIcon name="file-text" /></span>
+        <span><strong>1 份新报告待解读</strong><small>来自检验科 · 07-25 16:30</small></span>
+        <em>待解读</em>
+      </button>
+      <button type="button" @click="$emit('openAgent', 'tongue')">
+        <span class="status-icon tongue"><PortalIcon name="heart" /></span>
+        <span><strong>中医舌诊待完成</strong><small>问答已保存,继续拍摄舌象</small></span>
+        <em>继续</em>
+      </button>
+    </section>
+
+    <section class="featured-service">
+      <div class="featured-icon"><PortalIcon name="clipboard" /></div>
+      <div>
+        <span class="title-line"><h3>智能分诊挂号</h3><em>推荐</em></span>
+        <p>描述症状,智能推荐就诊科室和合适医生</p>
+        <button type="button" @click="$emit('openAgent', 'registration')"><PortalIcon name="speaker-loud" />语音挂号</button>
+      </div>
+    </section>
+
+    <section class="service-grid">
+      <button type="button" @click="$emit('openAgent', 'report')">
+        <span class="service-icon report"><PortalIcon name="file-text" /></span>
+        <strong>报告智能解读</strong>
+        <p>上传检验检查报告,查看重点指标</p>
+      </button>
+      <button type="button" @click="$emit('openAgent', 'tongue')">
+        <span class="service-icon tongue"><PortalIcon name="heart" /></span>
+        <strong>中医舌诊</strong>
+        <p>问答结合舌象,了解体质与调理建议</p>
+      </button>
+    </section>
+
+    <section class="recent-section">
+      <div class="section-heading">
+        <h3>最近记录</h3>
+        <button type="button" @click="$emit('openTab', 'records')">全部记录</button>
+      </div>
+      <button class="recent-record" type="button" @click="$emit('openAgent', 'registration')">
+        <span class="record-icon"><PortalIcon name="chat-bubble" /></span>
+        <span><strong>智能分诊记录</strong><small>2026-07-26 10:15</small></span>
+        <em>已推荐科室</em>
+        <PortalIcon name="chevron-right" />
+      </button>
+    </section>
+
+    <p class="demo-note"><PortalIcon name="info-circled" />演示环境 · 数据为 Mock</p>
+  </main>
+</template>
+
+<script setup lang="ts">
+import PortalIcon from '../components/PortalIcon.vue';
+import type { AgentMode, RootTab } from '../types';
+
+defineEmits<{
+  openAgent: [mode: AgentMode];
+  openTab: [tab: RootTab];
+}>();
+</script>

+ 32 - 0
src/portal/pages/MePage.vue

@@ -0,0 +1,32 @@
+<template>
+  <main class="portal-page me-page">
+    <header class="page-title">
+      <h1>我的</h1>
+      <p>本人信息与门户服务设置</p>
+    </header>
+    <section class="profile-card">
+      <span><PortalIcon name="person" /></span>
+      <div><strong>王*明</strong><small>当前就诊人 · 本人</small></div>
+    </section>
+    <section class="settings-list">
+      <button v-for="item in items" :key="item.label" type="button">
+        <PortalIcon :name="item.icon" />
+        <span><strong>{{ item.label }}</strong><small>{{ item.description }}</small></span>
+        <PortalIcon name="chevron-right" />
+      </button>
+    </section>
+    <p class="demo-note"><PortalIcon name="info-circled" />P0 暂不支持家庭成员代办</p>
+  </main>
+</template>
+
+<script setup lang="ts">
+import PortalIcon from '../components/PortalIcon.vue';
+
+const items = [
+  { label: '消息通知', description: '预约、报告和任务提醒', icon: 'bell' },
+  { label: '授权与隐私', description: '查看个人信息授权范围', icon: 'lock-closed' },
+  { label: '客服与人工服务', description: '获取医院人工帮助', icon: 'chat-bubble' },
+  { label: '使用帮助', description: '了解门户服务使用方式', icon: 'question-mark-circled' },
+  { label: '关于医院', description: '医院信息与联系方式', icon: 'home' },
+];
+</script>

+ 38 - 0
src/portal/pages/RecordsPage.vue

@@ -0,0 +1,38 @@
+<template>
+  <main class="portal-page records-page">
+    <header class="page-title">
+      <h1>健康记录</h1>
+      <p>查看结果或继续未完成任务</p>
+    </header>
+    <div class="filter-row">
+      <button v-for="item in filters" :key="item" type="button" :class="{ selected: filter === item }" @click="filter = item">{{ item }}</button>
+    </div>
+    <section class="record-list">
+      <button type="button" @click="$emit('openAgent', 'report')">
+        <span class="record-icon report"><PortalIcon name="file-text" /></span>
+        <span><strong>血常规智能解读</strong><small>今天 14:32 · 已完成</small><p>发现 3 项异常指标</p></span>
+        <em>查看</em>
+      </button>
+      <button type="button" @click="$emit('openAgent', 'tongue')">
+        <span class="record-icon tongue"><PortalIcon name="heart" /></span>
+        <span><strong>中医舌诊评估</strong><small>今天 13:10 · 待上传</small><p>问答已完成,待拍摄舌象</p></span>
+        <em>继续</em>
+      </button>
+      <button type="button" @click="$emit('openAgent', 'registration')">
+        <span class="record-icon registration"><PortalIcon name="calendar" /></span>
+        <span><strong>神经内科预约</strong><small>明天 09:30 · 已完成</small><p>李明 主任医师</p></span>
+        <em>查看</em>
+      </button>
+    </section>
+  </main>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import PortalIcon from '../components/PortalIcon.vue';
+import type { AgentMode } from '../types';
+
+defineEmits<{ openAgent: [mode: AgentMode] }>();
+const filters = ['全部', '挂号', '报告', '中医'];
+const filter = ref('全部');
+</script>

+ 97 - 0
src/portal/pages/ServicesPage.vue

@@ -0,0 +1,97 @@
+<template>
+  <main class="portal-page service-catalog-page">
+    <header class="catalog-header">
+      <span>医梦患者智能服务门户</span>
+      <h1>智能服务</h1>
+      <p>浏览医梦可为医院提供的智能体能力</p>
+    </header>
+
+    <label class="service-search">
+      <PortalIcon name="magnifying-glass" />
+      <input v-model="query" placeholder="搜索智能体服务" aria-label="搜索智能体服务" />
+    </label>
+
+    <div class="horizontal-tabs cluster-tabs" role="tablist" aria-label="智能体业务目录">
+      <button
+        v-for="item in agentCatalog"
+        :key="item.id"
+        type="button"
+        :class="{ selected: item.id === cluster.id }"
+        @click="selectCluster(item.id)"
+      >{{ item.label }}</button>
+    </div>
+
+    <div class="horizontal-tabs stage-tabs" role="tablist" :aria-label="`${cluster.label}阶段`">
+      <button
+        v-for="item in cluster.stages"
+        :key="item.id"
+        type="button"
+        :class="{ selected: item.id === stage.id }"
+        @click="stageId = item.id"
+      >{{ item.label }}</button>
+    </div>
+
+    <div class="catalog-summary">
+      <strong>{{ cluster.label }} · {{ stage.label }}</strong>
+      <span>{{ filteredAgents.length }} 项能力</span>
+    </div>
+
+    <section class="agent-catalog-list">
+      <button
+        v-for="agent in filteredAgents"
+        :key="agent.id"
+        type="button"
+        :disabled="!agent.mode"
+        :class="agent.mode ? 'enabled' : 'disabled'"
+        @click="agent.mode && $emit('openAgent', agent.mode)"
+      >
+        <span :class="['catalog-agent-icon', agent.mode || 'locked']">
+          <PortalIcon :name="agentIcon(agent.mode)" />
+        </span>
+        <span class="catalog-agent-copy">
+          <span class="catalog-agent-title">
+            <strong>{{ agent.title }}</strong>
+            <em>{{ agent.mode ? 'P0 已开通' : '本院未开通' }}</em>
+          </span>
+          <small>{{ agent.description }}</small>
+        </span>
+        <PortalIcon v-if="agent.mode" name="chevron-right" />
+      </button>
+    </section>
+
+    <p class="catalog-footnote">P0 仅开放智能分诊挂号、检验检查报告解读和中医舌诊,其余能力仅用于展示平台规划。</p>
+  </main>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import PortalIcon from '../components/PortalIcon.vue';
+import { agentCatalog } from '../catalog';
+import type { AgentMode } from '../types';
+
+defineEmits<{ openAgent: [mode: AgentMode] }>();
+
+const clusterId = ref('outpatient');
+const stageId = ref('before');
+const query = ref('');
+const cluster = computed(() => agentCatalog.find((item) => item.id === clusterId.value) ?? agentCatalog[0]);
+const stage = computed(() => cluster.value.stages.find((item) => item.id === stageId.value) ?? cluster.value.stages[0]);
+const filteredAgents = computed(() => {
+  const keyword = query.value.trim();
+  if (!keyword) return stage.value.agents;
+  return stage.value.agents.filter((agent) => `${agent.title}${agent.description}`.includes(keyword));
+});
+
+function selectCluster(id: string) {
+  clusterId.value = id;
+  const next = agentCatalog.find((item) => item.id === id) ?? agentCatalog[0];
+  stageId.value = next.stages[0].id;
+}
+
+function agentIcon(mode?: AgentMode) {
+  if (mode === 'registration') return 'calendar';
+  if (mode === 'report') return 'file-text';
+  if (mode === 'tongue') return 'heart';
+  return 'dashboard';
+}
+</script>

+ 1404 - 0
src/portal/portal.css

@@ -0,0 +1,1404 @@
+:root {
+  --portal-brand: #2b1f99;
+  --portal-brand-deep: #21147d;
+  --portal-brand-soft: #f1f0ff;
+  --portal-cyan: #2fc9d2;
+  --portal-cyan-soft: #e9fbfc;
+  --portal-bg: #f7fbff;
+  --portal-surface: #ffffff;
+  --portal-text: #16133a;
+  --portal-muted: #69708a;
+  --portal-border: #dfe7f7;
+  --portal-danger: #d94a4a;
+  --portal-danger-soft: #fff2f2;
+  --portal-success: #15996b;
+}
+
+html,
+body,
+#app {
+  min-width: 0;
+  height: 100%;
+  overflow: hidden;
+}
+
+body {
+  background: #edf0f7;
+}
+
+button,
+input {
+  font: inherit;
+}
+
+button:focus-visible,
+input:focus-visible {
+  outline: 2px solid color-mix(in srgb, var(--portal-brand) 50%, transparent);
+  outline-offset: 2px;
+}
+
+.portal-stage {
+  display: grid;
+  width: 100%;
+  height: 100dvh;
+  place-items: center;
+  overflow: hidden;
+  background: #edf0f7;
+}
+
+.portal-viewport {
+  position: relative;
+  width: min(100vw, 430px);
+  height: 100dvh;
+  overflow: hidden;
+  background: var(--portal-bg);
+  color: var(--portal-text);
+  box-shadow: 0 0 36px rgba(25, 29, 68, 0.14);
+}
+
+.portal-page {
+  width: 100%;
+  height: 100%;
+  padding: 78px 18px 92px;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+  scrollbar-width: none;
+}
+
+.portal-page::-webkit-scrollbar,
+.agent-conversation::-webkit-scrollbar,
+.horizontal-tabs::-webkit-scrollbar {
+  display: none;
+}
+
+.brand-header,
+.brand-lockup,
+.patient-chip,
+.assistant-entry,
+.section-heading,
+.patient-status-card > button,
+.featured-service,
+.title-line,
+.recent-record,
+.demo-note,
+.profile-card,
+.settings-list button,
+.task-header,
+.agent-session-intro,
+.conversation-upload,
+.message-file,
+.conversation-processing,
+.mcp-return-note {
+  display: flex;
+  align-items: center;
+}
+
+.brand-header {
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.brand-lockup {
+  gap: 10px;
+  min-width: 0;
+}
+
+.brand-mark {
+  display: grid;
+  width: 50px;
+  height: 50px;
+  flex: 0 0 50px;
+  place-items: center;
+  border-radius: 15px;
+  background: var(--portal-brand);
+  color: white;
+  box-shadow: 0 8px 20px rgba(43, 31, 153, 0.13);
+}
+
+.brand-mark svg {
+  width: 28px;
+  height: 28px;
+}
+
+.brand-lockup h1,
+.brand-lockup p,
+.welcome-block h2,
+.welcome-block p,
+.section-heading h3,
+.featured-service h3,
+.featured-service p,
+.service-grid strong,
+.service-grid p,
+.page-title h1,
+.page-title p,
+.catalog-header h1,
+.catalog-header p {
+  margin: 0;
+}
+
+.brand-lockup h1 {
+  font-size: 20px;
+  line-height: 1.2;
+}
+
+.brand-lockup p {
+  margin-top: 4px;
+  color: var(--portal-muted);
+  font-size: 11px;
+}
+
+.patient-chip {
+  gap: 6px;
+  min-height: 42px;
+  padding: 0 12px;
+  border-radius: 22px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+  font-size: 12px;
+  font-weight: 750;
+}
+
+.patient-chip svg {
+  width: 17px;
+  height: 17px;
+}
+
+.welcome-block {
+  margin-top: 28px;
+}
+
+.welcome-block h2 {
+  font-size: 27px;
+  letter-spacing: -0.03em;
+}
+
+.welcome-block > p {
+  margin-top: 6px;
+  color: var(--portal-muted);
+  font-size: 12px;
+}
+
+.assistant-entry {
+  gap: 10px;
+  width: 100%;
+  min-height: 66px;
+  margin-top: 20px;
+  padding: 0 14px;
+  border: 1px solid #d9deed;
+  border-radius: 20px;
+  background: white;
+  color: var(--portal-muted);
+  box-shadow: 0 8px 24px rgba(41, 43, 90, 0.05);
+}
+
+.assistant-entry > span {
+  display: grid;
+  width: 38px;
+  height: 38px;
+  flex: 0 0 38px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.assistant-entry strong {
+  flex: 1;
+  font-size: 12px;
+  font-weight: 600;
+  text-align: left;
+}
+
+.assistant-entry > svg {
+  color: var(--portal-cyan);
+}
+
+.patient-status-card {
+  margin-top: 16px;
+  overflow: hidden;
+  border: 1px solid var(--portal-border);
+  border-radius: 18px;
+  background: white;
+}
+
+.section-heading {
+  min-height: 46px;
+  padding: 0 13px;
+  justify-content: space-between;
+}
+
+.section-heading h3 {
+  font-size: 13px;
+}
+
+.section-heading > span {
+  color: var(--portal-brand);
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.section-heading > button {
+  color: var(--portal-brand);
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.patient-status-card > button {
+  width: 100%;
+  gap: 10px;
+  padding: 11px 13px;
+  border-top: 1px solid var(--portal-border);
+  background: white;
+  text-align: left;
+}
+
+.patient-status-card > button > span:nth-child(2) {
+  display: grid;
+  flex: 1;
+  gap: 2px;
+  min-width: 0;
+}
+
+.patient-status-card strong,
+.recent-record strong {
+  font-size: 11px;
+}
+
+.patient-status-card small,
+.recent-record small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.patient-status-card em,
+.recent-record em {
+  color: var(--portal-brand);
+  font-size: 9px;
+  font-style: normal;
+  font-weight: 750;
+}
+
+.status-icon,
+.record-icon {
+  display: grid;
+  width: 34px;
+  height: 34px;
+  flex: 0 0 34px;
+  place-items: center;
+  border-radius: 10px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.status-icon.report,
+.record-icon.report {
+  background: var(--portal-cyan-soft);
+  color: #168699;
+}
+
+.status-icon.tongue,
+.record-icon.tongue {
+  background: #fff0f1;
+  color: #c94e5c;
+}
+
+.featured-service {
+  gap: 18px;
+  margin-top: 16px;
+  padding: 18px;
+  border: 1px solid #dcdaf3;
+  border-radius: 20px;
+  background: white;
+  box-shadow: 0 8px 24px rgba(43, 31, 153, 0.05);
+}
+
+.featured-icon {
+  display: grid;
+  width: 86px;
+  height: 86px;
+  flex: 0 0 86px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.featured-icon svg {
+  width: 42px;
+  height: 42px;
+}
+
+.featured-service > div:last-child {
+  min-width: 0;
+}
+
+.title-line {
+  gap: 8px;
+}
+
+.title-line h3 {
+  font-size: 18px;
+}
+
+.title-line em {
+  color: var(--portal-cyan);
+  font-size: 9px;
+  font-style: normal;
+  font-weight: 800;
+}
+
+.featured-service p {
+  margin-top: 7px;
+  color: var(--portal-muted);
+  font-size: 10px;
+  line-height: 1.45;
+}
+
+.featured-service button {
+  display: inline-flex;
+  gap: 6px;
+  margin-top: 12px;
+  padding: 8px 13px;
+  align-items: center;
+  border-radius: 999px;
+  background: var(--portal-brand);
+  color: white;
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.service-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.service-grid > button {
+  min-height: 130px;
+  padding: 15px;
+  border: 1px solid var(--portal-border);
+  border-radius: 18px;
+  background: white;
+  text-align: left;
+}
+
+.service-icon {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  place-items: center;
+  border-radius: 13px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.service-icon.report {
+  background: var(--portal-cyan-soft);
+  color: #168699;
+}
+
+.service-icon.tongue {
+  background: #fff0f1;
+  color: #c94e5c;
+}
+
+.service-grid strong {
+  display: block;
+  margin-top: 12px;
+  font-size: 12px;
+}
+
+.service-grid p {
+  margin-top: 5px;
+  color: var(--portal-muted);
+  font-size: 9px;
+  line-height: 1.45;
+}
+
+.recent-section {
+  margin-top: 16px;
+}
+
+.recent-record {
+  width: 100%;
+  gap: 10px;
+  padding: 13px;
+  border: 1px solid var(--portal-border);
+  border-radius: 16px;
+  background: white;
+  text-align: left;
+}
+
+.recent-record > span:nth-child(2) {
+  display: grid;
+  flex: 1;
+  gap: 3px;
+}
+
+.demo-note {
+  gap: 5px;
+  margin: 16px 0 0;
+  justify-content: center;
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.portal-bottom-nav {
+  position: absolute;
+  z-index: 20;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  min-height: 68px;
+  border-top: 1px solid var(--portal-border);
+  background: rgba(255, 255, 255, 0.97);
+  backdrop-filter: blur(18px);
+}
+
+.portal-bottom-nav button {
+  display: grid;
+  gap: 3px;
+  place-content: center;
+  color: #7c8296;
+  font-size: 9px;
+}
+
+.portal-bottom-nav button svg {
+  width: 22px;
+  height: 22px;
+  margin: 0 auto;
+}
+
+.portal-bottom-nav button.active {
+  color: var(--portal-brand);
+  font-weight: 750;
+}
+
+.portal-bottom-nav button:focus-visible,
+.default-answer:focus-visible {
+  outline: 0;
+}
+
+.page-title h1 {
+  font-size: 24px;
+}
+
+.page-title p {
+  margin-top: 6px;
+  color: var(--portal-muted);
+  font-size: 12px;
+}
+
+.filter-row {
+  display: flex;
+  gap: 8px;
+  margin-top: 20px;
+}
+
+.filter-row button {
+  padding: 8px 14px;
+  border-radius: 999px;
+  background: white;
+  color: var(--portal-muted);
+  font-size: 10px;
+}
+
+.filter-row button.selected {
+  background: var(--portal-brand);
+  color: white;
+}
+
+.record-list,
+.settings-list {
+  display: grid;
+  gap: 10px;
+  margin-top: 16px;
+}
+
+.record-list > button,
+.settings-list > button {
+  display: grid;
+  grid-template-columns: 42px minmax(0, 1fr) auto;
+  gap: 11px;
+  padding: 14px;
+  align-items: center;
+  border: 1px solid var(--portal-border);
+  border-radius: 16px;
+  background: white;
+  text-align: left;
+}
+
+.record-list > button > span:nth-child(2),
+.settings-list > button > span:nth-child(2) {
+  display: grid;
+  gap: 3px;
+  min-width: 0;
+}
+
+.record-list strong,
+.settings-list strong,
+.profile-card strong {
+  font-size: 12px;
+}
+
+.record-list small,
+.settings-list small,
+.profile-card small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.record-list p {
+  margin: 2px 0 0;
+  color: var(--portal-muted);
+  font-size: 10px;
+}
+
+.record-list em {
+  color: var(--portal-brand);
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 750;
+}
+
+.profile-card {
+  gap: 13px;
+  margin-top: 22px;
+  padding: 18px;
+  border-radius: 18px;
+  background: var(--portal-brand);
+  color: white;
+}
+
+.profile-card > span {
+  display: grid;
+  width: 52px;
+  height: 52px;
+  place-items: center;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.16);
+}
+
+.profile-card > div {
+  display: grid;
+  gap: 4px;
+}
+
+.profile-card small {
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.settings-list > button > svg:first-child {
+  width: 22px;
+  height: 22px;
+  color: var(--portal-brand);
+}
+
+.settings-list > button > svg:last-child {
+  color: var(--portal-muted);
+}
+
+.service-catalog-page {
+  padding-inline: 0;
+  background: #f8f8fd;
+}
+
+.catalog-header,
+.service-search,
+.catalog-summary,
+.agent-catalog-list,
+.catalog-footnote {
+  margin-inline: 18px;
+}
+
+.catalog-header span {
+  display: block;
+  margin-bottom: 4px;
+  color: var(--portal-brand);
+  font-size: 10px;
+  font-weight: 800;
+  letter-spacing: 0.06em;
+}
+
+.catalog-header h1 {
+  font-size: 26px;
+}
+
+.catalog-header p {
+  margin-top: 6px;
+  color: var(--portal-muted);
+  font-size: 12px;
+}
+
+.service-search {
+  display: flex;
+  gap: 8px;
+  height: 46px;
+  margin-top: 18px;
+  padding: 0 14px;
+  align-items: center;
+  border: 1px solid #dadcef;
+  border-radius: 23px;
+  background: white;
+  box-shadow: 0 8px 22px rgba(50, 45, 98, 0.05);
+}
+
+.service-search svg {
+  width: 18px;
+  height: 18px;
+  color: #878ba3;
+}
+
+.service-search input {
+  width: 100%;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: var(--portal-text);
+  font-size: 12px;
+}
+
+.horizontal-tabs {
+  display: flex;
+  gap: 6px;
+  padding-inline: 18px;
+  overflow-x: auto;
+  scrollbar-width: none;
+}
+
+.cluster-tabs {
+  margin-top: 16px;
+}
+
+.horizontal-tabs button {
+  flex: 0 0 auto;
+  white-space: nowrap;
+}
+
+.cluster-tabs button {
+  position: relative;
+  padding: 10px 12px 12px;
+  color: #7b8098;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.cluster-tabs button.selected {
+  color: var(--portal-text);
+}
+
+.cluster-tabs button.selected::after {
+  position: absolute;
+  right: 28%;
+  bottom: 3px;
+  left: 28%;
+  height: 3px;
+  border-radius: 999px;
+  background: var(--portal-brand);
+  content: '';
+}
+
+.stage-tabs {
+  margin-top: 6px;
+}
+
+.stage-tabs button {
+  min-width: 64px;
+  padding: 8px 16px;
+  border: 1px solid #e4e5f1;
+  border-radius: 999px;
+  background: white;
+  color: #6f748b;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.stage-tabs button.selected {
+  border-color: var(--portal-brand);
+  background: var(--portal-brand);
+  color: white;
+}
+
+.catalog-summary {
+  display: flex;
+  margin-top: 18px;
+  margin-bottom: 9px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.catalog-summary strong {
+  font-size: 13px;
+}
+
+.catalog-summary span {
+  color: var(--portal-muted);
+  font-size: 10px;
+}
+
+.agent-catalog-list {
+  display: grid;
+  gap: 10px;
+}
+
+.agent-catalog-list > button {
+  display: grid;
+  grid-template-columns: 48px minmax(0, 1fr) auto;
+  gap: 11px;
+  min-height: 86px;
+  padding: 13px;
+  align-items: center;
+  border: 1px solid #e8e9f3;
+  border-radius: 18px;
+  background: white;
+  text-align: left;
+  box-shadow: 0 8px 24px rgba(46, 43, 88, 0.05);
+}
+
+.agent-catalog-list > button.enabled {
+  border-color: #d9d5fb;
+}
+
+.agent-catalog-list > button.disabled {
+  cursor: not-allowed;
+  opacity: 0.58;
+}
+
+.catalog-agent-icon {
+  display: grid;
+  width: 48px;
+  height: 48px;
+  place-items: center;
+  border-radius: 14px;
+  background: #f1f2f7;
+  color: #8a8fa5;
+}
+
+.catalog-agent-icon.registration {
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.catalog-agent-icon.report {
+  background: var(--portal-cyan-soft);
+  color: #168699;
+}
+
+.catalog-agent-icon.tongue {
+  background: #fff0f1;
+  color: #c94e5c;
+}
+
+.catalog-agent-icon svg {
+  width: 22px;
+  height: 22px;
+}
+
+.catalog-agent-copy {
+  min-width: 0;
+}
+
+.catalog-agent-title {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+}
+
+.catalog-agent-title strong {
+  overflow: hidden;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.catalog-agent-title em {
+  padding: 2px 5px;
+  border-radius: 4px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+  font-size: 8px;
+  font-style: normal;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.agent-catalog-list > button.disabled .catalog-agent-title em {
+  background: #f0f1f5;
+  color: #85899a;
+}
+
+.catalog-agent-copy > small {
+  display: -webkit-box;
+  margin-top: 7px;
+  overflow: hidden;
+  color: var(--portal-muted);
+  font-size: 10px;
+  line-height: 1.45;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.agent-catalog-list > button > svg {
+  width: 17px;
+  height: 17px;
+  color: var(--portal-brand);
+}
+
+.catalog-footnote {
+  margin-top: 16px;
+  color: #85899d;
+  font-size: 9px;
+  line-height: 1.55;
+}
+
+.agent-conversation-shell {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background: var(--portal-bg);
+}
+
+.task-header {
+  position: absolute;
+  z-index: 20;
+  top: 0;
+  right: 0;
+  left: 0;
+  display: grid;
+  grid-template-columns: 44px 1fr 44px;
+  height: 52px;
+  padding: 0 8px;
+  align-items: center;
+  border-bottom: 1px solid var(--portal-border);
+  background: rgba(255, 255, 255, 0.97);
+  backdrop-filter: blur(18px);
+}
+
+.task-header button {
+  display: grid;
+  width: 40px;
+  height: 40px;
+  place-items: center;
+  border-radius: 50%;
+}
+
+.task-header strong {
+  text-align: center;
+  font-size: 14px;
+}
+
+.agent-conversation {
+  position: absolute;
+  inset: 52px 0 0;
+  padding: 14px 13px 170px;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+  scrollbar-width: none;
+}
+
+.agent-session-intro {
+  gap: 10px;
+  padding: 10px 11px;
+  border: 1px solid var(--portal-border);
+  border-radius: 14px;
+  background: white;
+}
+
+.agent-session-intro .service-icon {
+  width: 40px;
+  height: 40px;
+  flex: 0 0 40px;
+}
+
+.agent-session-intro > div {
+  display: grid;
+  gap: 3px;
+}
+
+.agent-session-intro strong {
+  font-size: 12px;
+}
+
+.agent-session-intro small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.conversation-message {
+  width: fit-content;
+  max-width: 88%;
+  margin-top: 12px;
+  padding: 11px 13px;
+  border-radius: 15px;
+  font-size: 11px;
+  line-height: 1.6;
+}
+
+.conversation-message p {
+  margin: 0;
+}
+
+.conversation-message p + p {
+  margin-top: 7px;
+}
+
+.conversation-message.assistant {
+  border: 1px solid var(--portal-border);
+  border-bottom-left-radius: 4px;
+  background: white;
+}
+
+.conversation-message.user {
+  margin-left: auto;
+  border-bottom-right-radius: 4px;
+  background: var(--portal-brand);
+  color: white;
+}
+
+.conversation-message.user small {
+  display: block;
+  margin-bottom: 4px;
+  color: rgba(255, 255, 255, 0.72);
+  font-size: 8px;
+}
+
+.conversation-message.success {
+  border-color: #bfe6d7;
+  background: #effaf6;
+}
+
+.conversation-message.danger {
+  border-color: #f2caca;
+  background: var(--portal-danger-soft);
+  color: #7a2020;
+}
+
+.conversation-message.result {
+  width: calc(100% - 8px);
+  max-width: none;
+}
+
+.conversation-demo-prompt {
+  margin: 12px 7px;
+  padding: 14px;
+  border: 1px dashed #cfd4e7;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.conversation-demo-prompt > strong {
+  font-size: 10px;
+}
+
+.conversation-demo-prompt > p {
+  margin: 7px 0 10px;
+  font-size: 10px;
+}
+
+.conversation-demo-prompt > button {
+  display: flex;
+  gap: 7px;
+  width: 100%;
+  min-height: 40px;
+  align-items: center;
+  justify-content: center;
+  border-radius: 11px;
+  background: var(--portal-brand);
+  color: white;
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.conversation-demo-prompt > button.danger-action {
+  margin-top: 8px;
+  background: transparent;
+  color: var(--portal-danger);
+}
+
+.mcp-return-note {
+  gap: 6px;
+  margin: -4px 7px 10px 44px;
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.mcp-return-note span {
+  padding: 3px 5px;
+  border-radius: 999px;
+  background: #edf8f4;
+  color: #197759;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.slot-card {
+  margin-top: 12px;
+  padding: 13px;
+  border: 1px solid var(--portal-border);
+  border-radius: 15px;
+  background: white;
+}
+
+.slot-card > div {
+  display: flex;
+  justify-content: space-between;
+}
+
+.slot-card > div strong {
+  font-size: 12px;
+}
+
+.slot-card > div span {
+  padding: 3px 6px;
+  border-radius: 999px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+  font-size: 8px;
+  font-weight: 750;
+}
+
+.slot-card > p {
+  margin: 7px 0 9px;
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.slot-card > button {
+  display: flex;
+  width: 100%;
+  padding: 10px;
+  align-items: center;
+  justify-content: space-between;
+  border-top: 1px solid var(--portal-border);
+  text-align: left;
+}
+
+.slot-card > button span {
+  display: grid;
+  gap: 2px;
+}
+
+.slot-card > button small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.slot-card > button em {
+  color: var(--portal-brand);
+  font-size: 9px;
+  font-style: normal;
+  font-weight: 750;
+}
+
+.conversation-confirm,
+.inline-demo-action {
+  width: calc(100% - 16px);
+  min-height: 42px;
+  margin: 10px 8px 0;
+  border-radius: 12px;
+  background: var(--portal-brand);
+  color: white;
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.conversation-upload {
+  gap: 12px;
+  width: calc(100% - 16px);
+  margin: 12px 8px 0;
+  padding: 16px;
+  border: 1px dashed #b9c3e4;
+  border-radius: 15px;
+  background: white;
+  color: var(--portal-brand);
+  text-align: left;
+}
+
+.conversation-upload > svg {
+  width: 26px;
+  height: 26px;
+}
+
+.conversation-upload > span {
+  display: grid;
+  gap: 3px;
+}
+
+.conversation-upload strong {
+  color: var(--portal-text);
+  font-size: 11px;
+}
+
+.conversation-upload small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+.message-file {
+  gap: 6px;
+}
+
+.message-file svg {
+  width: 16px;
+  height: 16px;
+}
+
+.conversation-processing {
+  gap: 10px;
+}
+
+.conversation-processing > span {
+  width: 22px;
+  height: 22px;
+  flex: 0 0 22px;
+  border: 2px solid #d8d9ed;
+  border-top-color: var(--portal-brand);
+  border-radius: 50%;
+  animation: portal-spin 0.8s linear infinite;
+}
+
+.conversation-processing > div {
+  display: grid;
+  gap: 3px;
+}
+
+.conversation-processing small {
+  color: var(--portal-muted);
+  font-size: 9px;
+}
+
+@keyframes portal-spin {
+  to { transform: rotate(360deg); }
+}
+
+.structured-result h2,
+.structured-result h3,
+.structured-result p,
+.structured-result ul,
+.structured-result ol,
+.structured-result blockquote {
+  margin: 0;
+}
+
+.structured-result h2 {
+  font-size: 16px;
+}
+
+.structured-result h3 {
+  margin-top: 13px;
+  font-size: 12px;
+}
+
+.structured-result blockquote {
+  margin-top: 10px;
+  padding: 9px 10px;
+  border-left: 3px solid var(--portal-danger);
+  border-radius: 7px;
+  background: var(--portal-danger-soft);
+}
+
+.structured-result blockquote.positive {
+  border-left-color: var(--portal-success);
+  background: #effaf6;
+}
+
+.structured-result ul,
+.structured-result ol {
+  margin-top: 6px;
+  padding-left: 18px;
+}
+
+.structured-result.compact h3 {
+  margin-top: 0;
+}
+
+.result-table {
+  margin-top: 7px;
+  overflow: hidden;
+  border: 1px solid var(--portal-border);
+  border-radius: 10px;
+}
+
+.result-table > div {
+  display: grid;
+  grid-template-columns: 1.2fr 1fr 0.55fr;
+  gap: 6px;
+  padding: 7px 8px;
+  border-bottom: 1px solid var(--portal-border);
+  font-size: 9px;
+}
+
+.result-table > div:first-child {
+  background: #f5f7fd;
+}
+
+.result-table > div:last-child {
+  border-bottom: 0;
+}
+
+.result-table em {
+  color: var(--portal-danger);
+  font-style: normal;
+  font-weight: 750;
+}
+
+.conversation-end-space {
+  min-height: 12px;
+}
+
+.conversation-input-dock {
+  position: absolute;
+  z-index: 25;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: 8px 12px 10px;
+  border-top: 1px solid var(--portal-border);
+  background: rgba(244, 245, 253, 0.97);
+  backdrop-filter: blur(18px);
+}
+
+.default-answer {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  gap: 7px;
+  width: 100%;
+  margin-bottom: 7px;
+  padding: 8px 10px;
+  align-items: center;
+  border: 1px solid #d9dcf2;
+  border-radius: 12px;
+  background: white;
+  text-align: left;
+  box-shadow: 0 5px 14px rgba(47, 40, 112, 0.06);
+}
+
+.default-answer span {
+  padding: 3px 6px;
+  border-radius: 999px;
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+  font-size: 9px;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.default-answer strong {
+  overflow: hidden;
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.default-answer svg {
+  width: 14px;
+  height: 14px;
+  color: var(--portal-brand);
+}
+
+.conversation-composer {
+  display: grid;
+  grid-template-columns: 42px 1fr 42px;
+  gap: 7px;
+  min-height: 56px;
+  padding: 6px 7px;
+  align-items: center;
+  border: 1px solid #dfe3f2;
+  border-radius: 28px;
+  background: white;
+  box-shadow: 0 8px 24px rgba(33, 20, 125, 0.09);
+}
+
+.conversation-composer input:not(.visually-hidden) {
+  width: 100%;
+  height: 42px;
+  padding: 0 5px;
+  border: 0;
+  outline: 0;
+  background: white;
+  color: var(--portal-text);
+  font-size: 12px;
+}
+
+.composer-voice,
+.composer-camera {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  place-items: center;
+  border-radius: 50%;
+}
+
+.composer-voice {
+  background: var(--portal-brand-soft);
+  color: var(--portal-brand);
+}
+
+.composer-camera {
+  color: #272347;
+}
+
+.composer-voice svg,
+.composer-camera svg {
+  width: 19px;
+  height: 19px;
+}
+
+.conversation-input-dock > small {
+  display: block;
+  margin-top: 6px;
+  color: #858aa0;
+  font-size: 9px;
+  text-align: center;
+}
+
+.visually-hidden {
+  position: absolute !important;
+  width: 1px !important;
+  height: 1px !important;
+  padding: 0 !important;
+  overflow: hidden !important;
+  clip: rect(0, 0, 0, 0) !important;
+  white-space: nowrap !important;
+  border: 0 !important;
+}
+
+@media (max-width: 370px) {
+  .portal-page {
+    padding-inline: 14px;
+  }
+
+  .featured-icon {
+    width: 74px;
+    height: 74px;
+    flex-basis: 74px;
+  }
+
+  .catalog-header,
+  .service-search,
+  .catalog-summary,
+  .agent-catalog-list,
+  .catalog-footnote {
+    margin-inline: 14px;
+  }
+}
+
+@media (min-width: 431px) {
+  .portal-viewport {
+    border-inline: 1px solid rgba(210, 214, 228, 0.85);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    scroll-behavior: auto !important;
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+  }
+}

+ 21 - 0
src/portal/state/portalStore.ts

@@ -0,0 +1,21 @@
+import { defineStore } from 'pinia';
+import type { AgentMode, RootTab } from '../types';
+
+export const usePortalStore = defineStore('portal', {
+  state: () => ({
+    activeTab: 'home' as RootTab,
+    activeAgent: null as AgentMode | null,
+  }),
+  actions: {
+    switchTab(tab: RootTab) {
+      this.activeTab = tab;
+      this.activeAgent = null;
+    },
+    openAgent(mode: AgentMode) {
+      this.activeAgent = mode;
+    },
+    closeAgent() {
+      this.activeAgent = null;
+    },
+  },
+});

+ 21 - 0
src/portal/types.ts

@@ -0,0 +1,21 @@
+export type RootTab = 'home' | 'services' | 'records' | 'me';
+export type AgentMode = 'registration' | 'report' | 'tongue';
+
+export interface CatalogAgent {
+  id: string;
+  title: string;
+  description: string;
+  mode?: AgentMode;
+}
+
+export interface CatalogStage {
+  id: string;
+  label: string;
+  agents: CatalogAgent[];
+}
+
+export interface CatalogCluster {
+  id: string;
+  label: string;
+  stages: CatalogStage[];
+}

+ 12 - 21
tests/e2e/registration-flow.spec.ts

@@ -1,31 +1,22 @@
 import { expect, test } from '@playwright/test';
 
-test('demo registration flow reaches mock appointment success', async ({ page }) => {
+test('mobile portal registration flow completes slot filling and mock appointment', async ({ page }) => {
   await page.goto('/');
 
-  const input = page.getByPlaceholder('继续输入症状、科室、医生或时间...');
-  await input.click();
-  await input.fill('我头疼三天,还有点恶心,想挂号');
-  const sendBtn = page.getByRole('button', { name: '发送' });
-  await expect(sendBtn).toBeEnabled();
-  await sendBtn.click();
+  await page.getByRole('button', { name: '语音挂号' }).click();
 
-  await expect(page.getByText('推荐科室')).toBeVisible();
-  await page.getByRole('button', { name: '选择神经内科' }).click();
+  await expect(page.getByText('智能分诊挂号', { exact: true }).first()).toBeVisible();
+  await page.getByRole('button', { name: /推荐回答 我头疼/ }).click();
 
-  await expect(page.getByText('选择医生')).toBeVisible();
-  await page.getByRole('button', { name: /选择李明/ }).click();
+  await expect(page.getByText('还差最后一项:您想挂哪位医生?')).toBeVisible();
+  await page.getByRole('button', { name: '推荐回答 我想挂李明医生' }).click();
 
-  await expect(page.getByText('选择时间')).toBeVisible();
-  await page.getByRole('button', { name: /09:30/ }).click();
+  await expect(page.getByText('可预约号源')).toBeVisible();
+  await page.getByRole('button', { name: '推荐回答 我选择明天上午 09:00 的号源' }).click();
 
-  await expect(page.getByText('确认挂号信息')).toBeVisible();
-  await page.getByRole('button', { name: '确认挂号' }).click();
+  await expect(page.getByText('确认挂号信息')).toBeVisible();
+  await page.getByRole('button', { name: '推荐回答 确认以上挂号信息' }).click();
 
-  await expect(page.getByText('联调演示 / Mock 支付')).toBeVisible();
-  await page.getByRole('button', { name: '模拟支付完成' }).click();
-
-  await expect(page.getByText('挂号成功')).toBeVisible();
-  await expect(page.getByText('A023')).toBeVisible();
-  await expect(page.getByText('联调演示', { exact: true })).toBeVisible();
+  await expect(page.getByText('挂号成功(Mock)')).toBeVisible();
+  await expect(page.getByText('明天 09:00 · 神经内科 · 李明 主任医师')).toBeVisible();
 });

+ 19 - 8
tests/e2e/visual-layout.spec.ts

@@ -1,16 +1,27 @@
 import { expect, test } from '@playwright/test';
 
-test('desktop layout has no horizontal overflow and keeps right panel as static doctor idle state', async ({ page }) => {
+test('mobile web portal keeps the verified home hierarchy without horizontal overflow', async ({ page }) => {
   await page.goto('/');
 
-  await expect(page.getByText('医梦门诊助手')).toBeVisible();
-  await expect(page.getByText('说出需求,我来判断下一步')).toBeVisible();
-  await expect(page.getByAltText('医生助手')).toBeVisible();
-  await expect(page.getByText('当前仅作为静态形象展示')).toBeVisible();
-  await expect(page.getByRole('button', { name: '语音' })).toBeVisible();
-  await expect(page.getByRole('button', { name: '发送' })).toBeVisible();
-  await expect(page.getByText('智能挂号')).toHaveCount(0);
+  await expect(page.getByRole('heading', { name: '空海医院' })).toBeVisible();
+  await expect(page.getByRole('heading', { name: '今天需要什么帮助?' })).toBeVisible();
+  await expect(page.getByRole('heading', { name: '患者状态与待办' })).toBeVisible();
+  await expect(page.getByRole('button', { name: '按住说话,告诉我想办理什么' })).toBeVisible();
+  await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible();
 
   const horizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
   expect(horizontalOverflow).toBe(false);
 });
+
+test('service catalog exposes only three P0 agents', async ({ page }) => {
+  await page.goto('/');
+  await page.getByRole('button', { name: '服务', exact: true }).click();
+
+  await expect(page.getByRole('button', { name: /智能分诊挂号 P0 已开通/ })).toBeEnabled();
+  await expect(page.getByRole('button', { name: /智能导诊 本院未开通/ })).toBeDisabled();
+
+  await page.getByRole('button', { name: '诊中', exact: true }).click();
+  await expect(page.getByRole('button', { name: /中医舌诊 P0 已开通/ })).toBeEnabled();
+  await expect(page.getByRole('button', { name: /检验检查报告解读 P0 已开通/ })).toBeEnabled();
+  await expect(page.getByRole('button', { name: /临床决策支持 本院未开通/ })).toBeDisabled();
+});

+ 15 - 9
tests/unit/cardRenderer.spec.ts

@@ -1,10 +1,13 @@
 // @vitest-environment jsdom
 
 import { mount } from '@vue/test-utils';
-import { describe, expect, it } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { beforeEach, describe, expect, it } from 'vitest';
 import CardRenderer from '../../src/cards/CardRenderer.vue';
 
 describe('CardRenderer', () => {
+  beforeEach(() => setActivePinia(createPinia()));
+
   it('renders department card by cardKey', () => {
     const wrapper = mount(CardRenderer, {
       props: {
@@ -18,7 +21,7 @@ describe('CardRenderer', () => {
       }
     });
 
-    expect(wrapper.text()).toContain('推荐科室');
+    expect(wrapper.text()).toContain('推荐');
     expect(wrapper.text()).toContain('神经内科');
   });
 
@@ -35,7 +38,7 @@ describe('CardRenderer', () => {
       }
     });
 
-    expect(wrapper.text()).toContain('选择医生');
+    expect(wrapper.text()).toContain('推荐');
     expect(wrapper.text()).toContain('李明');
     expect(wrapper.text()).toContain('主任医师');
   });
@@ -53,7 +56,7 @@ describe('CardRenderer', () => {
       }
     });
 
-    expect(wrapper.text()).toContain('选择时间');
+    expect(wrapper.text()).toContain('6月5日');
     expect(wrapper.text()).toContain('明天 09:30-09:45');
   });
 
@@ -70,9 +73,9 @@ describe('CardRenderer', () => {
       }
     });
 
-    expect(wrapper.text()).toContain('确认挂号信息');
+    expect(wrapper.text()).toContain('确认预约信息');
     expect(wrapper.text()).toContain('神经内科');
-    expect(wrapper.text()).toContain('25');
+    expect(wrapper.text()).toContain('¥25');
   });
 
   it('renders payment QR card with mock label', () => {
@@ -88,7 +91,7 @@ describe('CardRenderer', () => {
       }
     });
 
-    expect(wrapper.text()).toContain('联调演示 / Mock 支付');
+    expect(wrapper.text()).toContain('联调演示 · Mock 支付');
     expect(wrapper.text()).toContain('模拟支付完成');
   });
 
@@ -139,8 +142,11 @@ describe('CardRenderer', () => {
       }
     });
 
-    await wrapper.find('button').trigger('click');
+    await wrapper.find('.dept-full').trigger('click');
     expect(wrapper.emitted('action')).toBeTruthy();
-    expect(wrapper.emitted('action')![0]).toEqual(['select_department']);
+    expect(wrapper.emitted('action')![0]).toEqual([{
+      actionName: 'select_department',
+      payload: { departmentId: 'neurology', departmentName: '神经内科' },
+    }]);
   });
 });

+ 29 - 0
tests/unit/portalStore.spec.ts

@@ -0,0 +1,29 @@
+import { createPinia, setActivePinia } from 'pinia';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { agentCatalog } from '../../src/portal/catalog';
+import { usePortalStore } from '../../src/portal/state/portalStore';
+
+describe('portalStore', () => {
+  beforeEach(() => setActivePinia(createPinia()));
+
+  it('opens and closes an agent without changing the current root tab', () => {
+    const store = usePortalStore();
+    store.switchTab('services');
+    store.openAgent('report');
+
+    expect(store.activeTab).toBe('services');
+    expect(store.activeAgent).toBe('report');
+
+    store.closeAgent();
+    expect(store.activeAgent).toBeNull();
+    expect(store.activeTab).toBe('services');
+  });
+
+  it('exposes exactly the three P0 agent entries', () => {
+    const enabled = agentCatalog.flatMap((cluster) =>
+      cluster.stages.flatMap((stage) => stage.agents.filter((agent) => agent.mode)),
+    );
+
+    expect(enabled.map((agent) => agent.mode).sort()).toEqual(['registration', 'report', 'tongue']);
+  });
+});

+ 9 - 0
vitest.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+  plugins: [vue()],
+  test: {
+    include: ['tests/unit/**/*.spec.ts'],
+  },
+});