Parcourir la source

feat: complete Adjutant P0 prototype flows

wangkangyjy il y a 1 jour
Parent
commit
d31f2ee186
35 fichiers modifiés avec 617 ajouts et 29 suppressions
  1. BIN
      artifacts/p0-complete-prototype/01-portal-home.png
  2. BIN
      artifacts/p0-complete-prototype/02-unified-assistant-route.png
  3. BIN
      artifacts/p0-complete-prototype/03-registration-schedule.png
  4. BIN
      artifacts/p0-complete-prototype/04-registration-emergency.png
  5. BIN
      artifacts/p0-complete-prototype/05-report-result.png
  6. BIN
      artifacts/p0-complete-prototype/06-tongue-result.png
  7. BIN
      artifacts/p0-complete-prototype/07-registration-failure.png
  8. BIN
      artifacts/p0-complete-prototype/08-portal-services.png
  9. BIN
      artifacts/p0-complete-prototype/09-portal-records.png
  10. BIN
      artifacts/p0-complete-prototype/10-registration-followup.png
  11. BIN
      artifacts/p0-complete-prototype/11-registration-complete.png
  12. BIN
      artifacts/p0-complete-prototype/12-registration-searching.png
  13. BIN
      artifacts/p0-complete-prototype/13-registration-confirm.png
  14. BIN
      artifacts/p0-complete-prototype/14-registration-processing.png
  15. BIN
      artifacts/p0-complete-prototype/15-registration-success.png
  16. BIN
      artifacts/p0-complete-prototype/20-report-entry.png
  17. BIN
      artifacts/p0-complete-prototype/21-report-upload.png
  18. BIN
      artifacts/p0-complete-prototype/22-report-failure.png
  19. BIN
      artifacts/p0-complete-prototype/23-report-confirm.png
  20. BIN
      artifacts/p0-complete-prototype/24-report-processing.png
  21. BIN
      artifacts/p0-complete-prototype/25-report-result.png
  22. BIN
      artifacts/p0-complete-prototype/26-report-critical.png
  23. BIN
      artifacts/p0-complete-prototype/30-tongue-entry.png
  24. BIN
      artifacts/p0-complete-prototype/31-tongue-intro.png
  25. BIN
      artifacts/p0-complete-prototype/32-tongue-question.png
  26. BIN
      artifacts/p0-complete-prototype/33-tongue-capture.png
  27. BIN
      artifacts/p0-complete-prototype/34-tongue-failure.png
  28. BIN
      artifacts/p0-complete-prototype/35-tongue-result.png
  29. BIN
      artifacts/p0-complete-prototype/boards/医梦P0_中医舌诊流程长图.png
  30. BIN
      artifacts/p0-complete-prototype/boards/医梦P0_报告解读流程长图.png
  31. BIN
      artifacts/p0-complete-prototype/boards/医梦P0_挂号智能体流程长图.png
  32. BIN
      artifacts/p0-complete-prototype/boards/医梦P0_门户总览长图.png
  33. 14 0
      prototype/adjutant-mobile/design-qa.md
  34. 358 29
      prototype/adjutant-mobile/src/Prototype.tsx
  35. 245 0
      prototype/adjutant-mobile/src/prototype.css

BIN
artifacts/p0-complete-prototype/01-portal-home.png


BIN
artifacts/p0-complete-prototype/02-unified-assistant-route.png


BIN
artifacts/p0-complete-prototype/03-registration-schedule.png


BIN
artifacts/p0-complete-prototype/04-registration-emergency.png


BIN
artifacts/p0-complete-prototype/05-report-result.png


BIN
artifacts/p0-complete-prototype/06-tongue-result.png


BIN
artifacts/p0-complete-prototype/07-registration-failure.png


BIN
artifacts/p0-complete-prototype/08-portal-services.png


BIN
artifacts/p0-complete-prototype/09-portal-records.png


BIN
artifacts/p0-complete-prototype/10-registration-followup.png


BIN
artifacts/p0-complete-prototype/11-registration-complete.png


BIN
artifacts/p0-complete-prototype/12-registration-searching.png


BIN
artifacts/p0-complete-prototype/13-registration-confirm.png


BIN
artifacts/p0-complete-prototype/14-registration-processing.png


BIN
artifacts/p0-complete-prototype/15-registration-success.png


BIN
artifacts/p0-complete-prototype/20-report-entry.png


BIN
artifacts/p0-complete-prototype/21-report-upload.png


BIN
artifacts/p0-complete-prototype/22-report-failure.png


BIN
artifacts/p0-complete-prototype/23-report-confirm.png


BIN
artifacts/p0-complete-prototype/24-report-processing.png


BIN
artifacts/p0-complete-prototype/25-report-result.png


BIN
artifacts/p0-complete-prototype/26-report-critical.png


BIN
artifacts/p0-complete-prototype/30-tongue-entry.png


BIN
artifacts/p0-complete-prototype/31-tongue-intro.png


BIN
artifacts/p0-complete-prototype/32-tongue-question.png


BIN
artifacts/p0-complete-prototype/33-tongue-capture.png


BIN
artifacts/p0-complete-prototype/34-tongue-failure.png


BIN
artifacts/p0-complete-prototype/35-tongue-result.png


BIN
artifacts/p0-complete-prototype/boards/医梦P0_中医舌诊流程长图.png


BIN
artifacts/p0-complete-prototype/boards/医梦P0_报告解读流程长图.png


BIN
artifacts/p0-complete-prototype/boards/医梦P0_挂号智能体流程长图.png


BIN
artifacts/p0-complete-prototype/boards/医梦P0_门户总览长图.png


+ 14 - 0
prototype/adjutant-mobile/design-qa.md

@@ -66,6 +66,20 @@ Browser console: no errors; only Vite connection and React development informati
   - `artifacts/product-design/conversational-task-ui/03-report-conversational-task.png`
   - `artifacts/product-design/conversational-task-ui/04-tongue-conversational-task.png`
 
+### Iteration 3 — complete P0 interaction prototype
+
+- Unified assistant: voice input now stops at an explicit route-confirmation step, shows the target service and inherited patient context, and only then enters the selected task.
+- Registration: completed doctor slot filling, 6/6 parameter confirmation, schedule query, schedule selection, Mock lock/submit, success result, no-slot/lock-failure recovery and emergency safety-gate branches.
+- Report interpretation: completed report selection/upload, OCR failure recovery, report ownership confirmation, processing, structured interpretation and high-risk priority guidance.
+- TCM tongue assessment: completed consent, questionnaire, tongue-image capture, existing MCP call, MCP failure recovery and combined result.
+- Portal linkage: the home page now exposes appointment, new-report and unfinished-tongue states; health records include completed, waiting and failed tasks with direct resume actions.
+- Browser verification: all normal, failure-recovery and medical-safety branches were clicked through in the in-app browser. Console errors: none.
+- Review boards:
+  - `artifacts/p0-complete-prototype/boards/医梦P0_门户总览长图.png`
+  - `artifacts/p0-complete-prototype/boards/医梦P0_挂号智能体流程长图.png`
+  - `artifacts/p0-complete-prototype/boards/医梦P0_报告解读流程长图.png`
+  - `artifacts/p0-complete-prototype/boards/医梦P0_中医舌诊流程长图.png`
+
 ## Follow-up polish
 
 - P3: add a real speech-to-text adapter when the P0 demo moves beyond scripted voice transcripts.

+ 358 - 29
prototype/adjutant-mobile/src/Prototype.tsx

@@ -23,6 +23,7 @@ import {
 } from "@radix-ui/react-icons";
 import {
   FlowStack,
+  KeyboardInput,
   KeyboardTextarea,
   MobileScroll,
   type FlowControls,
@@ -31,6 +32,7 @@ import {
 
 type RootTab = "home" | "services" | "records" | "me";
 type AssistantMode = "general" | "registration" | "report" | "tongue";
+type AssistantStage = "ready" | "routing" | "followup" | "complete";
 
 function PageHeader({ flow, title, step }: { flow: FlowControls; title: string; step?: string }) {
   return (
@@ -125,17 +127,40 @@ function HomeContent({ flow }: { flow: FlowControls }) {
           </span>
         </button>
 
-        <button className="todo-strip" type="button" onClick={() => flow.push(makeAssistantScreen("report"))}>
-          <span className="todo-label">
-            <FileTextIcon />
-            今日待办
-          </span>
-          <span className="todo-copy">
-            <strong>1 份新报告待解读</strong>
-            <small>来自检验科 · 07-25 16:30</small>
-          </span>
-          <span className="todo-action">查看</span>
-        </button>
+        <section className="patient-state-panel">
+          <div className="section-heading compact-heading">
+            <h3>患者状态与待办</h3>
+            <span>3 项</span>
+          </div>
+          <button className="patient-state-row appointment" type="button" onClick={() => flow.push(registrationResultScreen)}>
+            <span className="state-icon"><CalendarIcon /></span>
+            <span>
+              <strong>明天 09:30 神经内科预约</strong>
+              <small>李明 主任医师 · 请提前 15 分钟到院</small>
+            </span>
+            <ChevronRightIcon />
+          </button>
+          <button
+            className="patient-state-row report"
+            type="button"
+            onClick={() => flow.push(makeAssistantScreen("report", "帮我解读刚出的血常规报告", "complete"))}
+          >
+            <span className="state-icon"><FileTextIcon /></span>
+            <span>
+              <strong>1 份新报告待解读</strong>
+              <small>来自检验科 · 07-25 16:30</small>
+            </span>
+            <em>待解读</em>
+          </button>
+          <button className="patient-state-row tongue" type="button" onClick={() => flow.push(tongueCaptureScreen)}>
+            <span className="state-icon"><HeartIcon /></span>
+            <span>
+              <strong>中医舌诊待完成</strong>
+              <small>问答已保存,继续拍摄舌象</small>
+            </span>
+            <em>继续</em>
+          </button>
+        </section>
 
         <section className="service-section">
           <article className="registration-hero">
@@ -291,6 +316,16 @@ function RecordsContent({ flow }: { flow: FlowControls }) {
             <span><strong>神经内科预约</strong><small>明天 09:30 · 已完成</small><p>李明 主任医师</p></span>
             <em>查看</em>
           </button>
+          <button type="button" onClick={() => flow.push(registrationFailureScreen)}>
+            <span className="record-icon warning"><ReloadIcon /></span>
+            <span><strong>智能挂号任务</strong><small>今天 12:20 · 未完成</small><p>号源发生变化,挂号条件已保留</p></span>
+            <em>重试</em>
+          </button>
+          <button type="button" onClick={() => flow.push(reportFileFailureScreen)}>
+            <span className="record-icon warning"><FileTextIcon /></span>
+            <span><strong>报告解读任务</strong><small>今天 11:08 · 待重新上传</small><p>原图片模糊,患者信息已保留</p></span>
+            <em>继续</em>
+          </button>
         </section>
       </main>
     </MobileScroll>
@@ -358,11 +393,22 @@ const patientScreen: FlowScreen = {
   ),
 };
 
-function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: AssistantMode }) {
-  const [text, setText] = useState("");
-  const [stage, setStage] = useState<"ready" | "followup" | "complete">("ready");
+function AssistantExperience({
+  flow,
+  mode,
+  initialText = "",
+  initialStage = "ready",
+}: {
+  flow: FlowControls;
+  mode: AssistantMode;
+  initialText?: string;
+  initialStage?: AssistantStage;
+}) {
+  const [text, setText] = useState(initialText);
+  const [stage, setStage] = useState<AssistantStage>(initialStage);
   const [doctor, setDoctor] = useState("");
   const [editingSlot, setEditingSlot] = useState("");
+  const [routeIntent, setRouteIntent] = useState<Exclude<AssistantMode, "general">>("registration");
   const [slots, setSlots] = useState({
     department: "神经内科",
     date: "明天 07-27",
@@ -393,10 +439,20 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
   }[mode];
 
   const start = () => {
-    if (text.includes("报告")) flow.push(reportUploadScreen);
-    else if (text.includes("舌") || text.includes("体质")) flow.push(tongueIntroScreen);
-    else if (text.includes("胸痛") || text.includes("呼吸困难")) flow.push(emergencyScreen);
-    else {
+    const submittedText = text || modeCopy.sample;
+    setText(submittedText);
+    if (submittedText.includes("胸痛") || submittedText.includes("呼吸困难")) {
+      flow.push(emergencyScreen);
+    } else if (mode === "general") {
+      if (submittedText.includes("报告")) setRouteIntent("report");
+      else if (submittedText.includes("舌") || submittedText.includes("体质")) setRouteIntent("tongue");
+      else setRouteIntent("registration");
+      setStage("routing");
+    } else if (mode === "report") {
+      setStage("complete");
+    } else if (mode === "tongue") {
+      setStage("complete");
+    } else {
       setText(text || modeCopy.sample);
       setStage("followup");
     }
@@ -404,11 +460,35 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
 
   const simulateVoice = () => {
     setText(modeCopy.sample);
-    if (mode === "report") setStage("complete");
+    if (mode === "general") {
+      setRouteIntent("registration");
+      setStage("routing");
+    } else if (mode === "report") setStage("complete");
     else if (mode === "tongue") setStage("complete");
     else setStage("followup");
   };
 
+  const demonstrateRoute = (intent: Exclude<AssistantMode, "general"> | "emergency") => {
+    if (intent === "emergency") {
+      setText("我胸痛,还喘不过气,想挂明天心内科");
+      flow.push(emergencyScreen);
+      return;
+    }
+    const routeSamples = {
+      registration: "我头疼,帮我挂神经内科明天早上九点的专家号",
+      report: "帮我解读刚出的血常规报告",
+      tongue: "我最近容易疲倦,想做一次中医舌诊",
+    };
+    setText(routeSamples[intent]);
+    setRouteIntent(intent);
+    setStage("routing");
+  };
+
+  const enterRoutedTask = () => {
+    const routedStage: AssistantStage = routeIntent === "registration" ? "followup" : "complete";
+    flow.replace(makeAssistantScreen(routeIntent, text, routedStage));
+  };
+
   const chooseDoctor = (name: string) => {
     setDoctor(name);
     setStage("complete");
@@ -417,7 +497,7 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
   const nextTask = () => {
     if (mode === "report") flow.push(reportUploadScreen);
     else if (mode === "tongue") flow.push(tongueIntroScreen);
-    else flow.push(scheduleScreen);
+    else flow.push(registrationSearchingScreen);
   };
 
   const updateSlot = (key: keyof typeof slots, value: string) => {
@@ -476,15 +556,62 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
             </label>
             {mode === "general" ? (
               <section>
-                <h3 className="minor-heading">也可以直接选择</h3>
+                <h3 className="minor-heading">演示不同路由</h3>
                 <div className="quick-actions">
-                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("registration"))}>语音挂号</button>
-                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("report"))}>报告解读</button>
-                  <button type="button" onClick={() => flow.replace(makeAssistantScreen("tongue"))}>中医舌诊</button>
+                  <button type="button" onClick={() => demonstrateRoute("registration")}>挂号诉求</button>
+                  <button type="button" onClick={() => demonstrateRoute("report")}>报告诉求</button>
+                  <button type="button" onClick={() => demonstrateRoute("tongue")}>舌诊诉求</button>
+                  <button className="danger-chip" type="button" onClick={() => demonstrateRoute("emergency")}>急诊风险</button>
                 </div>
               </section>
             ) : null}
           </>
+        ) : stage === "routing" ? (
+          <section className="slot-dialogue route-dialogue" aria-live="polite">
+            <div className="dialogue-context">
+              <span className="dialogue-label">需求识别</span>
+              <article className="voice-transcript">
+                <span>您说</span>
+                <p>{text}</p>
+              </article>
+              <article className="assistant-reply">
+                <strong>我已经理解您的需求</strong>
+                <p>
+                  {routeIntent === "registration"
+                    ? "您希望根据头痛症状完成神经内科预约。继续前会先完成安全判断和缺失信息补充。"
+                    : routeIntent === "report"
+                      ? "您希望解读最新的血常规报告。进入后需要确认报告来源和归属。"
+                      : "您希望通过体质问答和舌象分析了解近期疲倦与体质情况。"}
+                </p>
+              </article>
+            </div>
+            <section className={`route-confirm-card ${routeIntent}`}>
+              <span className="route-kicker">即将进入</span>
+              <div className="route-title">
+                {routeIntent === "registration" ? <CalendarIcon /> : routeIntent === "report" ? <FileTextIcon /> : <HeartIcon />}
+                <div>
+                  <strong>
+                    {routeIntent === "registration" ? "智能分诊挂号" : routeIntent === "report" ? "报告智能解读" : "中医舌诊"}
+                  </strong>
+                  <small>
+                    {routeIntent === "registration"
+                      ? "已带入:头疼、神经内科、明天 09:00、专家号"
+                      : routeIntent === "report"
+                        ? "已带入:最新血常规报告"
+                        : "已带入:容易疲倦、了解体质"}
+                  </small>
+                </div>
+              </div>
+              <button className="primary-button inline-action" type="button" onClick={enterRoutedTask}>
+                确认并进入服务 <ChevronRightIcon />
+              </button>
+            </section>
+            <section className="notice-card neutral route-note">
+              <InfoCircledIcon />
+              <div><strong>统一助手只负责路由</strong><p>确认后将创建独立业务任务,不在通用会话中继续办理。</p></div>
+            </section>
+            <button className="text-button" type="button" onClick={() => setStage("ready")}>重新描述需求</button>
+          </section>
         ) : (
           <section className="slot-dialogue" aria-live="polite">
             <div className="dialogue-context">
@@ -605,7 +732,7 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
                       <button type="button" onClick={() => chooseDoctor("不限医生")}>不限医生</button>
                     </div>
                     <label className="task-text-fallback">
-                      <input
+                      <KeyboardInput
                         value={doctor}
                         onChange={(event) => setDoctor(event.target.value)}
                         placeholder="也可以输入医生姓名"
@@ -639,14 +766,25 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
   );
 }
 
-function makeAssistantScreen(mode: AssistantMode): FlowScreen {
+function makeAssistantScreen(
+  mode: AssistantMode,
+  initialText = "",
+  initialStage: AssistantStage = "ready",
+): FlowScreen {
   const title = mode === "registration" ? "语音挂号" : mode === "report" ? "报告解读" : mode === "tongue" ? "中医舌诊" : "智能助手";
   return {
-    id: `assistant-${mode}`,
+    id: `assistant-${mode}-${initialStage}`,
     title,
     headerHeight: 52,
     header: (flow) => <PageHeader flow={flow} title={title} />,
-    render: (flow) => <AssistantExperience flow={flow} mode={mode} />,
+    render: (flow) => (
+      <AssistantExperience
+        flow={flow}
+        mode={mode}
+        initialText={initialText}
+        initialStage={initialStage}
+      />
+    ),
   };
 }
 
@@ -797,6 +935,72 @@ const departmentScreen: FlowScreen = {
   ),
 };
 
+const registrationSearchingScreen: FlowScreen = {
+  id: "registration-searching",
+  title: "查询号源",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="智能分诊挂号" step="查询中" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page processing-page">
+        <span className="processing-icon"><CalendarIcon /></span>
+        <h2>正在查询医生与号源</h2>
+        <p>正在按神经内科、明天上午、专家号和李明主任的条件查询 Mock 号源。</p>
+        <div className="processing-steps">
+          <span className="done"><CheckCircledIcon /> 挂号信息完整性检查</span>
+          <span className="done"><CheckCircledIcon /> 急诊风险门禁通过</span>
+          <span><ReloadIcon /> 查询医生排班与实时号源</span>
+        </div>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(scheduleScreen)}>
+          查看可用号源 <ChevronRightIcon />
+        </button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(registrationFailureScreen)}>
+          演示无号源 / 查询失败
+        </button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const registrationFailureScreen: FlowScreen = {
+  id: "registration-failure",
+  title: "号源发生变化",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="智能分诊挂号" step="需要处理" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page result-page failure-page">
+        <span className="result-icon failure"><CrossCircledIcon /></span>
+        <h2>当前号源暂不可用</h2>
+        <p>可能是号源已被占用或 Mock HIS 暂时未响应。您的挂号条件已经保留。</p>
+        <section className="preserved-context">
+          <div><span>科室</span><strong>神经内科</strong></div>
+          <div><span>日期与时间</span><strong>明天 上午</strong></div>
+          <div><span>号别</span><strong>专家号</strong></div>
+          <div><span>医生</span><strong>李明 主任医师</strong></div>
+        </section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(registrationSearchingScreen)}>
+          保留条件,重新查询 <ReloadIcon />
+        </button>
+        <button
+          className="secondary-button"
+          type="button"
+          onClick={() => flow.replace(makeAssistantScreen(
+            "registration",
+            "我头疼,帮我挂神经内科明天早上九点的专家号,李明主任",
+            "complete",
+          ))}
+        >
+          返回修改挂号条件
+        </button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>
+          稍后处理,在健康记录中继续
+        </button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
 function ScheduleExperience({ flow }: { flow: FlowControls }) {
   const [slot, setSlot] = useState("09:30");
   return (
@@ -846,7 +1050,7 @@ const registrationConfirmScreen: FlowScreen = {
   headerHeight: 52,
   header: (flow) => <PageHeader flow={flow} title="确认挂号" step="5/5" />,
   footerHeight: 82,
-  footer: (flow) => <FixedAction label="确认并模拟支付" onClick={() => flow.push(registrationResultScreen)} />,
+  footer: (flow) => <FixedAction label="确认并模拟执行" onClick={() => flow.push(registrationProcessingScreen)} />,
   render: () => (
     <MobileScroll className="task-scroll has-action-footer">
       <main className="task-page">
@@ -867,6 +1071,33 @@ const registrationConfirmScreen: FlowScreen = {
   ),
 };
 
+const registrationProcessingScreen: FlowScreen = {
+  id: "registration-processing",
+  title: "正在办理挂号",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="确认挂号" step="执行中" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page processing-page">
+        <span className="processing-icon"><ReloadIcon /></span>
+        <h2>正在模拟锁号与挂号</h2>
+        <p>当前为 P0 演示,不会产生真实预约或支付。</p>
+        <div className="processing-steps">
+          <span className="done"><CheckCircledIcon /> 挂号信息再次确认</span>
+          <span className="done"><CheckCircledIcon /> Mock 锁号成功</span>
+          <span><ReloadIcon /> 创建挂号记录与模拟支付</span>
+        </div>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(registrationResultScreen)}>
+          完成模拟挂号
+        </button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(registrationFailureScreen)}>
+          演示锁号失效
+        </button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
 const registrationResultScreen: FlowScreen = {
   id: "registration-result",
   title: "挂号结果",
@@ -910,6 +1141,42 @@ const reportUploadScreen: FlowScreen = {
           <button type="button" onClick={() => flow.push(reportConfirmScreen)}><FileTextIcon /><span><strong>血常规</strong><small>本院已审核 · 重点演示血小板指标</small></span><ChevronRightIcon /></button>
           <button type="button" onClick={() => flow.push(reportConfirmScreen)}><FileTextIcon /><span><strong>甲状腺超声</strong><small>本院已审核 · 检查报告文字解读</small></span><ChevronRightIcon /></button>
         </div>
+        <button className="failure-demo-link" type="button" onClick={() => flow.push(reportFileFailureScreen)}>
+          <CrossCircledIcon /> 演示图片模糊 / OCR 失败
+        </button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const reportFileFailureScreen: FlowScreen = {
+  id: "report-file-failure",
+  title: "报告无法识别",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="报告智能解读" step="需要重传" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page result-page failure-page">
+        <span className="result-icon failure"><CrossCircledIcon /></span>
+        <h2>这张报告暂时无法识别</h2>
+        <p>图片存在明显模糊,部分指标名称和数值无法可靠读取。</p>
+        <section className="failure-reasons">
+          <strong>请重新拍摄并检查</strong>
+          <ul>
+            <li>报告四角完整入镜</li>
+            <li>文字清晰,无反光和遮挡</li>
+            <li>避免倾斜或距离过远</li>
+          </ul>
+        </section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(reportUploadScreen)}>
+          重新上传报告 <UploadIcon />
+        </button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(reportConfirmScreen)}>
+          改用演示血常规
+        </button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>
+          保存任务,稍后继续
+        </button>
       </main>
     </MobileScroll>
   ),
@@ -970,6 +1237,7 @@ const reportProcessingScreen: FlowScreen = {
           <span><ReloadIcon /> 指标标准化与知识检索</span>
         </div>
         <button className="primary-button inline-action" type="button" onClick={() => flow.replace(reportResultScreen)}>查看解读结果</button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(reportFileFailureScreen)}>演示 OCR 识别失败</button>
       </main>
     </MobileScroll>
   ),
@@ -999,6 +1267,9 @@ const reportResultScreen: FlowScreen = {
           <h3>下一步建议</h3>
           <p>如出现皮肤出血点、鼻出血或牙龈出血,建议尽快联系医生;可咨询血液科或原就诊科室。</p>
         </section>
+        <button className="risk-demo-link" type="button" onClick={() => flow.push(reportCriticalScreen)}>
+          <ExclamationTriangleIcon /> 演示报告高风险提示
+        </button>
         <button className="primary-button inline-action" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>保存并查看健康记录</button>
         <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("home"))}>返回首页</button>
         <small className="rule-note">当前阈值为演示配置,正式上线以医院确认规则为准。</small>
@@ -1007,6 +1278,33 @@ const reportResultScreen: FlowScreen = {
   ),
 };
 
+const reportCriticalScreen: FlowScreen = {
+  id: "report-critical",
+  title: "报告风险提示",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="报告智能解读" step="风险优先" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll danger-surface">
+      <main className="task-page emergency-page report-critical-page">
+        <span className="emergency-icon"><ExclamationTriangleIcon /></span>
+        <h2>请优先联系医生确认</h2>
+        <p>演示规则识别到血小板结果可能需要尽快复核。风险提示会先于普通指标解释展示。</p>
+        <section className="emergency-actions stacked">
+          <button type="button">联系原就诊科室</button>
+          <button type="button">查看院内急诊位置</button>
+        </section>
+        <section className="notice-card neutral">
+          <InfoCircledIcon />
+          <div><strong>当前为演示规则</strong><p>正式上线后必须使用医院检验科确认的危急值配置。</p></div>
+        </section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.pop()}>
+          已了解,返回完整解读
+        </button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
 const tongueIntroScreen: FlowScreen = {
   id: "tongue-intro",
   title: "中医舌诊",
@@ -1090,6 +1388,37 @@ const tongueProcessingScreen: FlowScreen = {
           <span><ReloadIcon /> 问答与舌象证据融合</span>
         </div>
         <button className="primary-button inline-action" type="button" onClick={() => flow.replace(tongueResultScreen)}>查看综合结果</button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(tongueFailureScreen)}>演示舌诊 MCP 失败</button>
+      </main>
+    </MobileScroll>
+  ),
+};
+
+const tongueFailureScreen: FlowScreen = {
+  id: "tongue-failure",
+  title: "舌诊分析未完成",
+  headerHeight: 52,
+  header: (flow) => <PageHeader flow={flow} title="中医舌诊" step="可恢复" />,
+  render: (flow) => (
+    <MobileScroll className="task-scroll">
+      <main className="task-page result-page failure-page">
+        <span className="result-icon failure"><CrossCircledIcon /></span>
+        <h2>本次分析暂未完成</h2>
+        <p>舌诊 MCP 暂时没有返回结果。已保留问答内容和合格舌象,不需要重新开始。</p>
+        <section className="preserved-context">
+          <div><span>体质问答</span><strong>已保存</strong></div>
+          <div><span>舌象图片</span><strong>质量合格</strong></div>
+          <div><span>分析状态</span><strong>等待重试</strong></div>
+        </section>
+        <button className="primary-button inline-action" type="button" onClick={() => flow.replace(tongueProcessingScreen)}>
+          重新调用舌诊 MCP <ReloadIcon />
+        </button>
+        <button className="secondary-button" type="button" onClick={() => flow.replace(tongueCaptureScreen)}>
+          重新拍摄舌象
+        </button>
+        <button className="text-button" type="button" onClick={() => flow.replace(makeRootScreen("records"))}>
+          保存任务,稍后继续
+        </button>
       </main>
     </MobileScroll>
   ),

+ 245 - 0
prototype/adjutant-mobile/src/prototype.css

@@ -2228,6 +2228,251 @@ button {
   color: var(--muted);
 }
 
+.patient-state-panel {
+  margin-top: 14px;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 18px;
+  background: var(--surface);
+  box-shadow: 0 8px 24px rgba(38, 41, 90, 0.035);
+}
+
+.compact-heading {
+  min-height: 46px;
+  padding: 0 14px;
+  background: #f5f7ff;
+}
+
+.compact-heading h3 {
+  font-size: 14px;
+}
+
+.compact-heading > span {
+  color: var(--brand);
+  font-size: 11px;
+  font-weight: 750;
+}
+
+.patient-state-row {
+  display: flex;
+  width: 100%;
+  min-height: 66px;
+  padding: 10px 12px;
+  align-items: center;
+  gap: 10px;
+  border: 0;
+  border-top: 1px solid var(--border);
+  background: var(--surface);
+  text-align: left;
+}
+
+.patient-state-row > span:nth-child(2) {
+  min-width: 0;
+  flex: 1;
+}
+
+.patient-state-row strong,
+.patient-state-row small {
+  display: block;
+}
+
+.patient-state-row strong {
+  font-size: 12px;
+}
+
+.patient-state-row small {
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 10px;
+  line-height: 1.35;
+}
+
+.patient-state-row > em {
+  color: var(--brand);
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 750;
+}
+
+.state-icon {
+  display: grid;
+  width: 36px;
+  height: 36px;
+  flex: 0 0 36px;
+  place-items: center;
+  border-radius: 11px;
+  background: var(--brand-soft);
+  color: var(--brand);
+}
+
+.patient-state-row.report .state-icon {
+  background: #eef5ff;
+  color: #276fe2;
+}
+
+.patient-state-row.tongue .state-icon {
+  background: var(--cyan-soft);
+  color: #11a5ae;
+}
+
+.quick-actions .danger-chip {
+  border-color: #efb1b1;
+  background: var(--danger-soft);
+  color: #a23b3b;
+}
+
+.route-confirm-card {
+  padding: 16px;
+  border: 1px solid #cfd8f2;
+  border-radius: 18px;
+  background: #f7f8ff;
+}
+
+.route-confirm-card.report {
+  border-color: #c7d9fb;
+  background: #f4f8ff;
+}
+
+.route-confirm-card.tongue {
+  border-color: #bfeaed;
+  background: #f1fcfc;
+}
+
+.route-kicker {
+  color: var(--muted);
+  font-size: 11px;
+  font-weight: 750;
+}
+
+.route-title {
+  display: flex;
+  margin-top: 10px;
+  align-items: flex-start;
+  gap: 11px;
+}
+
+.route-title > svg {
+  width: 25px;
+  height: 25px;
+  flex: 0 0 25px;
+  color: var(--brand);
+}
+
+.route-confirm-card.report .route-title > svg {
+  color: #276fe2;
+}
+
+.route-confirm-card.tongue .route-title > svg {
+  color: #11a5ae;
+}
+
+.route-title strong,
+.route-title small {
+  display: block;
+}
+
+.route-title strong {
+  font-size: 16px;
+}
+
+.route-title small {
+  margin-top: 6px;
+  color: var(--muted);
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.route-note {
+  margin-top: 0;
+}
+
+.failure-demo-link {
+  display: flex;
+  width: 100%;
+  min-height: 44px;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  margin-top: 16px;
+  border: 1px dashed #e4a2a2;
+  border-radius: 13px;
+  background: var(--danger-soft);
+  color: #a23b3b;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.result-icon.failure {
+  background: var(--danger-soft);
+  color: var(--danger);
+}
+
+.failure-page > h2 {
+  color: #a32929;
+}
+
+.preserved-context,
+.failure-reasons {
+  width: 100%;
+  margin-top: 20px;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 16px;
+  background: var(--surface);
+  text-align: left;
+}
+
+.preserved-context > div {
+  display: flex;
+  min-height: 46px;
+  padding: 0 14px;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--border);
+}
+
+.preserved-context > div:last-child {
+  border-bottom: 0;
+}
+
+.preserved-context span {
+  color: var(--muted);
+  font-size: 11px;
+}
+
+.preserved-context strong {
+  font-size: 12px;
+}
+
+.failure-reasons {
+  padding: 15px;
+}
+
+.failure-reasons strong {
+  font-size: 13px;
+}
+
+.failure-reasons ul {
+  margin: 9px 0 0;
+  padding-left: 18px;
+  color: var(--muted);
+  font-size: 11px;
+  line-height: 1.9;
+}
+
+.emergency-actions.stacked {
+  grid-template-columns: 1fr;
+}
+
+.report-critical-page .notice-card {
+  text-align: left;
+}
+
+.record-icon.warning {
+  background: #fff4e8;
+  color: #c0780d;
+}
+
 @media (max-width: 390px) {
   .portal-page {
     padding-inline: 15px;