患者服务台移动端原型实施计划.md 40 KB


doc_id: DEV-202607-001 feature_id: FEAT-202606-001-unified-entry-client type: dev-progress title: 患者服务台移动端原型实施计划 status: implemented owner: 医梦研发团队 created_at: 2026-07-02 updated_at: 2026-07-03 reviewers:

  • 产品负责人
  • 终端负责人 related_docs:
  • DES-202607-001
  • ODS-202606-002
  • ADR-202606-001 related_modules:
  • emoon-terminal-client tags:
  • 患者服务台
  • 移动端原型
  • Open Design
  • 事件驱动 ---

患者服务台移动端原型 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在现有 Open Design 项目中建设一个可交互的患者手机端小程序/H5 方案演示原型,完整演示报告事件、患者授权、AI 解读、自然语言挂号、 Command 确认、到院签到和候诊更新。

Architecture: 使用单页移动端应用承载全部演示投影。纯状态模型保存 activeAgentTaskProjection(单个或 null)、patientWorkItemsjourneyProjectionsresultSnapshotProjectionscommandProjections; 视图只根据投影渲染,不使用 Card 类型推进流程。场景控制器注入患者服务台私有 事件 fixture,状态函数仅模拟消费服务端 SSE/业务事件投影,不创建或修改服务端事实。

本计划只建设纯前端方案演示原型,不进行后端联调,也不构成 FEAT-006 OpenAPI/SSE 或生产契约。REPORT_ISSUEDCHECKIN_AVAILABLEQUEUE_UPDATEDVISIT_* 等均为患者服务台原型私有场景事件假设;真实契约需另立专题。

Tech Stack: HTML5、CSS3、原生 JavaScript ES Modules、Node.js 内置 node:test、Lucide 图标、Open Design HTML Artifact。


1. 实施范围

1.1 源码与发布目录

版本化源码目录:

/Users/destiny/dev/emoon/emoon-backend/
docs/prototypes/patient-service-desk-v3/

Open Design 发布目录:

/Users/destiny/Library/Application Support/Open Design/namespaces/
release-stable/data/projects/b7a08855-87bd-493c-92a4-31655a384570/

1.2 文件结构

docs/prototypes/patient-service-desk-v3/
├── assets/
│   └── doctor.png
├── patient-service-desk-v3.css
├── patient-service-desk-v3.html
├── patient-service-desk-v3.js
├── patient-service-desk-v3.state.mjs
└── patient-service-desk-v3.test.mjs

Open Design 发布目录/
├── patient-service-desk-v3.css
├── patient-service-desk-v3.html
├── patient-service-desk-v3.html.artifact.json
├── patient-service-desk-v3.js
├── patient-service-desk-v3.state.mjs
└── index.html

文件职责:

文件 职责
patient-service-desk-v3.html 移动端 App Shell、页面容器、对话框和演示控制器容器
patient-service-desk-v3.css 品牌 Token、移动端布局、组件状态和动效
patient-service-desk-v3.state.mjs 服务端事实的只读 fixture/projection 和演示场景转换
patient-service-desk-v3.js DOM 渲染、用户交互、导航和无障碍行为
patient-service-desk-v3.test.mjs 纯状态模型测试
.artifact.json Open Design Artifact 元数据
index.html 将 v3 原型加入现有原型索引

不修改现有 v1/v2 HTML,以便历史对比;这不代表生产环境存在双轨协议或双轨运行。

除 Task 10 发布步骤外,所有命令从版本化源码目录执行。每个任务的 Git checkpoint 提交 emoon-backend 中的原型源码,不提交 Open Design 应用数据目录。

2. 统一演示数据

原型固定使用以下演示人物和业务数据:

export const demoPatient = {
  id: "patient-zhang-san",
  name: "张三",
  relation: "本人",
  hospital: "空海医院",
  campus: "本部院区"
};

export const demoReport = {
  id: "report-blood-20260702",
  type: "血常规",
  issuedAt: "2026-07-02 10:18",
  reviewStatus: "FINAL",
  abnormalCount: 3,
  totalCount: 18,
  summary: "3 项指标轻度异常,未发现需要立即急诊处理的信号"
};

export const demoAppointment = {
  department: "神经内科",
  doctor: "李明",
  title: "主任医师",
  visitAt: "2026-07-03 09:30",
  location: "门诊三楼 302",
  fee: 25
};

3. 状态模型

状态模型至少包含:

export const initialState = {
  activeTab: "home",
  activePatientId: "patient-zhang-san",
  appliedProjectionFixtureIds: [],
  activeAgentTaskProjection: null,
  patientWorkItems: [],
  journeyProjections: [],
  resultSnapshotProjections: {},
  commandProjections: {},
  messages: [],
  consents: {},
  overlays: {
    commandId: null,
    consentId: null,
    scenarioPanelOpen: false
  }
};

患者服务台首页事项投影结构:

{
  id: "work-item-report-001",
  type: "REPORT_REVIEW",
  status: "ACTION_REQUIRED",
  stage: "REPORT_AVAILABLE",
  priority: 90,
  title: "血常规报告已出",
  description: "18 项指标中有 3 项超出参考范围",
  primaryAction: "INTERPRET_REPORT",
  secondaryAction: "VIEW_REPORT",
  expiresAt: null
}

服务端活动任务投影结构为单个对象或 null,同一会话不得出现多个 ACTIVE。Command 结构是只读 projection:

{
  id: "command-register-001",
  type: "REGISTER_APPOINTMENT",
  riskLevel: "L2",
  status: "AWAITING_CONFIRMATION",
  version: 1,
  expiresAt: "2026-07-02T15:02:00+08:00",
  summary: {
    department: "神经内科",
    doctor: "李明 主任医师",
    visitAt: "7月3日 09:30",
    fee: "¥25.00"
  }
}

L2 Command 确认链严格为:

PREPARED → AWAITING_CONFIRMATION → EXECUTING → SUCCEEDED | FAILED | UNKNOWN

L1 明确交互授权后可直接 PREPARED → EXECUTING → SUCCEEDED | FAILED | UNKNOWN,不额外二次确认。 另有 REJECTEDEXPIRED;不得用 COMPLETED 表示 Command 成功。 L0 是查询;L1 是可逆临时动作且明确交互即授权;L2 是不可逆/收费动作并二次确认; L3 需要医护/人工确认。签到、报告授权尚未在 FEAT-006 定级,仅作为原型假设。

4. 实施任务

Task 1: 创建状态模型和基础测试

Files:

  • Create: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.state.mjs
  • Create: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写服务端投影 fixture 消费失败测试

    import test from "node:test";
    import assert from "node:assert/strict";
    import {
    createInitialState,
    applyServerProjectionFixture
    } from "./patient-service-desk-v3.state.mjs";
    
    test("REPORT_ISSUED fixture creates one patient work item projection", () => {
    const state = createInitialState();
    const next = applyServerProjectionFixture(state, {
    id: "event-report-001",
    type: "REPORT_ISSUED",
    occurredAt: "2026-07-02T10:18:00+08:00",
    payload: { reportId: "report-blood-20260702" }
    });
    
    assert.equal(next.patientWorkItems.length, 1);
    assert.equal(next.patientWorkItems[0].type, "REPORT_REVIEW");
    assert.equal(next.patientWorkItems[0].status, "ACTION_REQUIRED");
    assert.equal(next.activeAgentTaskProjection, null);
    });
    
  • [ ] Step 2: 运行测试并确认失败

Run:

node --test patient-service-desk-v3.test.mjs

Expected: FAIL,提示状态模块或导出函数不存在。

  • [ ] Step 3: 实现不可变服务端投影 fixture 转换

    export function createInitialState() {
    return {
    activeTab: "home",
    activePatientId: "patient-zhang-san",
    appliedProjectionFixtureIds: [],
    activeAgentTaskProjection: null,
    patientWorkItems: [],
    journeyProjections: [],
    resultSnapshotProjections: {},
    commandProjections: {},
    messages: [],
    consents: {},
    overlays: {
      commandId: null,
      consentId: null,
      scenarioPanelOpen: false
    }
    };
    }
    
    export function applyServerProjectionFixture(state, fixture) {
    if (state.appliedProjectionFixtureIds.includes(fixture.id)) {
    return state;
    }
    
    const next = {
    ...state,
    appliedProjectionFixtureIds: [
      ...state.appliedProjectionFixtureIds,
      fixture.id
    ],
    patientWorkItems: [...state.patientWorkItems],
    messages: [...state.messages]
    };
    
    if (fixture.type === "REPORT_ISSUED") {
    next.patientWorkItems.push({
      id: "work-item-report-001",
      type: "REPORT_REVIEW",
      status: "ACTION_REQUIRED",
      stage: "REPORT_AVAILABLE",
      priority: 90,
      title: "血常规报告已出",
      description: "18 项指标中有 3 项超出参考范围",
      primaryAction: "INTERPRET_REPORT",
      secondaryAction: "VIEW_REPORT",
      expiresAt: null
    });
    next.messages.push({
      id: "message-report-001",
      type: "REPORT",
      unread: true,
      title: "血常规报告已出",
      createdAt: fixture.occurredAt
    });
    }
    
    return next;
    }
    
  • [ ] Step 4: 增加事件幂等测试

    test("duplicate event does not duplicate task or message", () => {
    const fixture = {
    id: "event-report-001",
    type: "REPORT_ISSUED",
    occurredAt: "2026-07-02T10:18:00+08:00",
    payload: { reportId: "report-blood-20260702" }
    };
    const once = applyServerProjectionFixture(
    createInitialState(),
    fixture
    );
    const twice = applyServerProjectionFixture(once, fixture);
    
    assert.equal(twice.patientWorkItems.length, 1);
    assert.equal(twice.messages.length, 1);
    assert.equal(twice.activeAgentTaskProjection, null);
    });
    
  • [ ] Step 5: 运行测试并确认通过

Run:

node --test patient-service-desk-v3.test.mjs

Expected: 2 tests PASS。

  • [ ] Step 6: Commit

    git add patient-service-desk-v3.state.mjs patient-service-desk-v3.test.mjs
    git commit -m "feat: add patient desk event state model"
    

Task 2: 建立移动端 App Shell 和设计 Token

Files:

  • Create: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.html
  • Create: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.css
  • Create: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.js
  • Copy: /Users/destiny/Library/Application Support/Open Design/namespaces/release-stable/data/projects/b7a08855-87bd-493c-92a4-31655a384570/doctor.png to docs/prototypes/patient-service-desk-v3/assets/doctor.png

  • [ ] Step 1: 创建语义化 HTML 骨架

    <body>
    <div class="prototype-stage">
    <main class="phone-shell" aria-label="空海医院患者服务台">
      <header id="appHeader"></header>
      <section id="appView" aria-live="polite"></section>
      <nav id="bottomNav" aria-label="主要导航"></nav>
    </main>
    <aside id="scenarioPanel" aria-label="演示场景控制"></aside>
    </div>
    <div id="overlayRoot"></div>
    <script
    src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js">
    </script>
    <script type="module" src="./patient-service-desk-v3.js"></script>
    </body>
    
  • [ ] Step 2: 建立移动端 Token

    :root {
    --bg: #f6f8fc;
    --surface: #ffffff;
    --surface-soft: #f0f5ff;
    --fg: #1a1835;
    --muted: #706d89;
    --border: #e5e8f2;
    --brand: #2b1f99;
    --ai: #3ad4d8;
    --success: #1aa981;
    --warning: #e58a16;
    --danger: #d74747;
    --radius-sm: 10px;
    --radius-md: 16px;
    --radius-lg: 24px;
    --shadow-card: 0 8px 24px rgb(43 31 153 / 8%);
    --tap-size: 44px;
    }
    
    .phone-shell {
    width: min(100%, 430px);
    min-height: 844px;
    background: var(--bg);
    border-radius: 32px;
    overflow: hidden;
    position: relative;
    }
    
    button,
    a {
    min-height: var(--tap-size);
    }
    
  • [ ] Step 3: 实现五导航定义

    export const navItems = [
    { id: "home", label: "首页", icon: "house" },
    { id: "care", label: "就医", icon: "stethoscope" },
    { id: "assistant", label: "AI助手", icon: "sparkles" },
    { id: "records", label: "健康档案", icon: "folder-heart" },
    { id: "profile", label: "我的", icon: "user-round" }
    ];
    
  • [ ] Step 4: 加载页面并检查基础结构

Run:

open patient-service-desk-v3.html

Expected:

  • 430px 手机容器完整显示;
  • 五个底部导航均可见;
  • 页面没有水平滚动;
  • Lucide 图标加载成功;
  • 浏览器控制台无 JavaScript 错误。

  • [ ] Step 5: Commit/checkpoint

    git add patient-service-desk-v3.html patient-service-desk-v3.css patient-service-desk-v3.js
    git commit -m "feat: scaffold mobile patient service desk"
    

Task 3: 实现首页和任务排序

Files:

  • Modify: patient-service-desk-v3.state.mjs
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写任务排序失败测试

    import { selectHomeTasks } from "./patient-service-desk-v3.state.mjs";
    
    test("home work items are sorted by priority and completion", () => {
    const state = createInitialState();
    state.patientWorkItems = [
    { id: "done", priority: 100, status: "COMPLETED" },
    { id: "payment", priority: 80, status: "ACTION_REQUIRED" },
    { id: "report", priority: 90, status: "ACTION_REQUIRED" }
    ];
    
    assert.deepEqual(
    selectHomeTasks(state).map(item => item.id),
    ["report", "payment", "done"]
    );
    });
    
  • [ ] Step 2: 运行测试并确认失败

Run:

node --test patient-service-desk-v3.test.mjs

Expected: FAIL,selectHomeTasks 未定义。

  • [ ] Step 3: 实现任务选择器

    const statusRank = {
    ACTION_REQUIRED: 0,
    IN_PROGRESS: 1,
    UPCOMING: 2,
    COMPLETED: 3
    };
    
    export function selectHomeTasks(state) {
    return [...state.patientWorkItems].sort((left, right) => {
    const statusDelta =
      statusRank[left.status] - statusRank[right.status];
    return statusDelta || right.priority - left.priority;
    });
    }
    
  • [ ] Step 4: 实现首页组件

首页按顺序渲染:

function renderHome(state) {
  return `
    ${renderHospitalHeader(state)}
    ${renderGreeting(state)}
    ${renderTaskSection(selectHomeTasks(state))}
    ${renderTodayJourney(state)}
    ${renderServiceGrid()}
    ${renderCareSection(state)}
  `;
}

必须实现:

  • 医院和就诊人;
  • 未读消息角标;
  • AI 轻量输入入口;
  • “现在要做”任务卡;
  • 今日旅程;
  • 八个常用服务;
  • 主动关怀。

  • [ ] Step 5: 运行测试并检查首页

Run:

node --test patient-service-desk-v3.test.mjs

Expected: 全部 PASS。

Manual:

  • 空闲场景显示“今天没有待处理事项”;
  • 注入报告投影 fixture 后报告事项排第一;
  • 已完成任务不抢占第一屏;
  • 第一屏只存在一个主 CTA。

  • [ ] Step 6: Commit/checkpoint

    git add patient-service-desk-v3.state.mjs patient-service-desk-v3.js \
    patient-service-desk-v3.css patient-service-desk-v3.test.mjs
    git commit -m "feat: add task-driven patient desk home"
    

Task 4: 实现报告授权、详情和 AI 解读

Files:

  • Modify: patient-service-desk-v3.state.mjs
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写未授权不能解读的失败测试

    import {
    requestReportInterpretation,
    grantConsent
    } from "./patient-service-desk-v3.state.mjs";
    
    test("report interpretation requires active consent", () => {
    const state = applyServerProjectionFixture(createInitialState(), {
    id: "event-report-001",
    type: "REPORT_ISSUED",
    occurredAt: "2026-07-02T10:18:00+08:00",
    payload: { reportId: "report-blood-20260702" }
    });
    
    const blocked = requestReportInterpretation(
    state,
    "report-blood-20260702"
    );
    assert.equal(blocked.overlays.consentId, "consent-report-ai");
    assert.equal(
    blocked.patientWorkItems[0].stage,
    "CONSENT_REQUIRED"
    );
    
    const allowed = requestReportInterpretation(
    grantConsent(blocked, "consent-report-ai"),
    "report-blood-20260702"
    );
    assert.equal(
    allowed.patientWorkItems[0].stage,
    "INTERPRETING"
    );
    });
    
  • [ ] Step 2: 运行测试并确认失败

Run:

node --test patient-service-desk-v3.test.mjs

Expected: FAIL,授权函数未定义。

  • Step 3: 实现授权和解读状态转换

授权对象固定包含:

{
  id: "consent-report-ai",
  purpose: "用于本次报告的 AI 辅助解释",
  scope: "当前血常规报告",
  expiresAt: "2026-07-09T23:59:59+08:00",
  revocable: true,
  status: "GRANTED"
}

患者服务台事项投影转换(报告授权风险定级为原型假设,待后续专题确认):

REPORT_AVAILABLE
→ CONSENT_REQUIRED
→ INTERPRETING
→ INTERPRETED
→ FOLLOWUP_RECOMMENDED
  • Step 4: 实现四个报告视图
  1. 报告授权;
  2. 报告详情;
  3. AI 解读加载;
  4. AI 解读结果。

AI 结果必须按以下结构展示:

const interpretation = {
  headline: "3 项指标轻度异常",
  safety:
    "未发现需要立即急诊处理的信号,请结合症状由医生判断",
  highlights: [
    { name: "白细胞计数", value: "11.2", flag: "偏高" },
    { name: "中性粒细胞比例", value: "78%", flag: "偏高" },
    { name: "淋巴细胞比例", value: "18%", flag: "偏低" }
  ],
  nextActions: [
    "预约神经内科复诊",
    "在线咨询医生",
    "设置复查提醒"
  ]
};
  • Step 5: 验证授权和患者安全

Manual:

  • 未授权点击“AI 解读”只打开授权层;
  • 拒绝授权后仍能查看原始报告;
  • 解读页不出现“确诊”“一定是”等确定性表述;
  • 撤回授权后不能重新打开历史 AI 解读;
  • 返回首页后报告任务状态保持一致。

  • [ ] Step 6: 运行测试并确认通过

Run:

node --test patient-service-desk-v3.test.mjs

Expected: 全部 PASS。

  • [ ] Step 7: Commit/checkpoint

    git add patient-service-desk-v3.*
    git commit -m "feat: add consented report interpretation flow"
    

Task 5: 实现 AI 助手和自然语言挂号

Files:

  • Modify: patient-service-desk-v3.state.mjs
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写完整条件不重复补问测试

    import { submitInteraction } from "./patient-service-desk-v3.state.mjs";
    
    test("complete registration request produces slot candidates directly", () => {
    const next = submitInteraction(
    createInitialState(),
    "我想挂明天神经内科李明主任的号"
    );
    
    const task = next.activeAgentTaskProjection;
    assert.equal(task.type, "REGISTRATION");
    assert.equal(task.stage, "CANDIDATE_SELECTION");
    assert.equal(task.missingFields.length, 0);
    assert.ok(task.activeResultRef);
    assert.equal(
    next.resultSnapshotProjections[task.activeResultRef].items.length,
    3
    );
    });
    
  • [ ] Step 2: 运行测试并确认失败

Run:

node --test patient-service-desk-v3.test.mjs

Expected: FAIL,interaction 未定义。

  • Step 3: 实现挂号演示解析器

原型只识别演示句:

我想挂明天神经内科李明主任的号

模拟后端返回单一活动 TaskState 的只读投影:

{
  type: "REGISTRATION",
  stage: "CANDIDATE_SELECTION",
  constraints: {
    departmentName: "神经内科",
    doctorName: "李明",
    doctorTitle: "主任医师",
    visitDate: "2026-07-03"
  },
  missingFields: [],
  activeResultRef: "result-slot-001"
}

生成三个号源候选,必须使用稳定 candidateId,不能只依赖序号。 ResultSnapshot 的文本回答和 Presentation 必须同源;租户、患者、会话绑定由 后端负责。原型只展示投影,并覆盖版本冲突或过期后“结果已更新,请重新查询”的恢复态。

  • Step 4: 实现 AI 对话页面

包含:

  • 场景建议;
  • 消息列表;
  • 完整文本回答;
  • 号源候选 Presentation;
  • 固定底部输入框;
  • 返回当前任务;
  • 无结果替代推荐状态。

  • [ ] Step 5: 实现点击和文字统一选择

以下操作必须得到相同结果:

点击 candidate-slot-0930
输入“选第一个”

两者都解析为:

{
  interactionType: "SELECT_CANDIDATE",
  resultRef: "result-slot-001",
  resultVersion: 1,
  candidateId: "candidate-slot-0930"
}

选择后必须依次模拟:

校验 ResultSnapshot version / expiry
→ 实时复核号源
→ L1 LOCK_APPOINTMENT_SLOT(明确选择即授权,不额外二次确认)
→ 锁号成功后返回更新的 activeAgentTaskProjection
→ 准备 L2 REGISTER_APPOINTMENT
→ 患者二次确认

自然语言与点击都只能提交 resultRef + resultVersion + candidateId,客户端不得 创建锁号或挂号 Command,也不得直接修改服务端 TaskState。

  • [ ] Step 6: 写 ResultSnapshot 冲突和过期恢复测试

    import {
    applyCandidateSelectionResponseFixture
    } from "./patient-service-desk-v3.state.mjs";
    
    for (const errorCode of [
    "RESULT_VERSION_CONFLICT",
    "RESULT_EXPIRED"
    ]) {
    test(`${errorCode} enters requery recovery state`, () => {
    const state = submitInteraction(
      createInitialState(),
      "我想挂明天神经内科李明主任的号"
    );
    const recovered = applyCandidateSelectionResponseFixture(state, {
      id: `selection-error-${errorCode}`,
      errorCode
    });
    
    assert.equal(
      recovered.activeAgentTaskProjection.stage,
      "REQUERY_REQUIRED"
    );
    assert.equal(
      recovered.activeAgentTaskProjection.activeResultRef,
      null
    );
    assert.match(
      recovered.messages.at(-1).text,
      /结果已更新,请重新查询/
    );
    });
    }
    

该 fixture 模拟后端对 ResultSnapshot 的租户、患者、会话、version 和 expiry 校验失败响应;客户端清除失效引用并进入重新查询恢复态,不使用旧候选重试。

  • Step 7: 运行测试并手工检查

Expected:

  • 完整条件直接返回候选;
  • 不出现科室卡、医生卡、时间卡链;
  • Presentation 隐藏后文字仍完整;
  • 点击和“选第一个”选择同一 candidateId。
  • 快照版本冲突、过期或实时复核失败时回到重新查询恢复态;
  • 锁号成功后才出现 L2 挂号确认。

  • [ ] Step 8: Commit/checkpoint

    git add patient-service-desk-v3.*
    git commit -m "feat: add task-aware registration assistant"
    

Task 6: 实现 Command 确认和异常状态

Files:

  • Modify: patient-service-desk-v3.state.mjs
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写重复确认和 UNKNOWN 测试

    import {
    applyCommandProjectionFixture,
    buildCommandConfirmationRequest
    } from "./patient-service-desk-v3.state.mjs";
    
    test("duplicate succeeded fixture does not duplicate result", () => {
    const awaiting = applyCommandProjectionFixture(
    createInitialState(),
    {
      id: "command-awaiting-001",
      commandId: "command-register-001",
      version: 1,
      status: "AWAITING_CONFIRMATION"
    }
    );
    const request = buildCommandConfirmationRequest(
    awaiting,
    "command-register-001",
    {
      channel: "CARD",
      idempotencyKey: "confirm-register-001"
    }
    );
    assert.equal(
    request.path,
    "/api/v2/commands/command-register-001/confirm"
    );
    assert.equal(request.commandId, "command-register-001");
    assert.equal(request.body.commandVersion, 1);
    assert.deepEqual(request.body.confirmation, {
    channel: "CARD",
    explicit: true
    });
    assert.equal(
    request.headers["X-Emoon-Idempotency-Key"],
    "confirm-register-001"
    );
    
    const succeededFixture = {
    id: "command-succeeded-001",
    commandId: "command-register-001",
    status: "SUCCEEDED",
    resultFixtureId: "appointment-created-001"
    };
    const succeeded = applyCommandProjectionFixture(
    awaiting,
    succeededFixture
    );
    const repeated = applyCommandProjectionFixture(
    succeeded,
    succeededFixture
    );
    
    assert.equal(
    repeated.commandProjections["command-register-001"].status,
    "SUCCEEDED"
    );
    assert.equal(repeated.appliedProjectionFixtureIds.filter(
    id => id === "appointment-created-001"
    ).length, 1);
    });
    
    test("unknown command projection blocks confirmation request", () => {
    const unknown = applyCommandProjectionFixture(
    createInitialState(),
    {
      id: "command-unknown-001",
      commandId: "command-register-001",
      status: "UNKNOWN"
    }
    );
    
    assert.throws(
    () => buildCommandConfirmationRequest(
      unknown,
      "command-register-001",
      {
        channel: "CARD",
        idempotencyKey: "confirm-register-001"
      }
    ),
    /结果确认中/
    );
    });
    

buildCommandConfirmationRequest 模拟 POST /api/v2/commands/{commandId}/confirm,请求必须携带 commandVersionconfirmationX-Emoon-Idempotency-Key。Command 确认不经过 Agent interaction;只有候选选择使用 interactionType。全部状态变化仍由 applyCommandProjectionFixture 模拟消费服务端 SSE/业务结果投影。客户端不得 创建 CommandInstance、直接推进 Command 状态或写入服务端 TaskState。

  • Step 2: 运行测试并确认失败

Expected: FAIL,Command 投影函数未定义。

  • Step 3: 实现 Command 投影状态机

允许的原型投影转换:

L2: PREPARED → AWAITING_CONFIRMATION
AWAITING_CONFIRMATION → EXECUTING | REJECTED | EXPIRED
EXECUTING → SUCCEEDED | FAILED | UNKNOWN
UNKNOWN → SUCCEEDED | FAILED

L1: PREPARED → EXECUTING
EXECUTING → SUCCEEDED | FAILED | UNKNOWN

L1 由用户明确交互授权,可直接执行而不额外二次确认。任何其他转换抛出明确错误, 且不得使用 COMPLETED 作为 Command 状态。

  • Step 4: 实现统一确认层

确认层显示:

  • 操作名称;
  • 就诊人;
  • 科室、医生和时间;
  • 金额;
  • 有效倒计时;
  • 确认和暂不操作。

实现以下视觉状态:

  • 待确认;
  • 执行中;
  • 成功;
  • 失败;
  • 已过期;
  • 结果确认中;
  • 已拒绝。

  • [ ] Step 5: 验证 Command 安全表达

Manual:

  • 页面不能编辑金额和号源参数;
  • Command projection 必须来自服务端 fixture,客户端不得自行创建;
  • 重复点击确认只产生一次成功投影;
  • UNKNOWN 显示“请勿重复操作”;
  • 过期后只允许重新查询号源;
  • 成功后首页出现待到院 PatientWorkItem/JourneyProjection

  • [ ] Step 6: 运行测试并确认通过

Expected: 全部 PASS。

  • [ ] Step 7: Commit/checkpoint

    git add patient-service-desk-v3.*
    git commit -m "feat: add governed command confirmation states"
    

Task 7: 实现到院、签到和候诊投影 fixture

Files:

  • Modify: patient-service-desk-v3.state.mjs
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 写位置投影不自动签到测试

    test("arrival fixture offers check-in but does not execute it", () => {
    const next = applyServerProjectionFixture(createInitialState(), {
    id: "event-arrival-001",
    type: "CHECKIN_AVAILABLE",
    occurredAt: "2026-07-03T09:10:00+08:00",
    payload: { location: "门诊三楼神经内科诊区" }
    });
    
    const item = next.patientWorkItems.find(
    workItem => workItem.type === "CHECK_IN"
    );
    assert.equal(item.status, "ACTION_REQUIRED");
    assert.equal(item.stage, "CONFIRMATION_REQUIRED");
    assert.equal(
    next.appliedProjectionFixtureIds.includes(
      "checkin-completed-001"
    ),
    false
    );
    });
    
  • [ ] Step 2: 运行测试并确认失败

Expected: FAIL,签到事件未处理。

  • Step 3: 实现私有场景 fixture 到只读投影的转换

支持:

APPOINTMENT_CREATED → 待到院
CHECKIN_AVAILABLE → 待确认签到
CHECKIN_COMPLETED → 候诊中
QUEUE_UPDATED → 更新人数和预计时间
VISIT_STARTED → 就诊中
VISIT_COMPLETED → 诊后服务

以上事件名全部是患者服务台原型私有场景假设,不是 FEAT-006 SSE/OpenAPI。 转换只更新 patientWorkItemsjourneyProjections 和 messages,不创建 TaskState 或 Command。签到尚未在 FEAT-006 定级,不得模拟为已确定 Command。

  • Step 4: 实现今日旅程和候诊页

候诊页展示:

  • 当前排队号;
  • 前方人数;
  • 预计时间;
  • 当前诊室;
  • 导航;
  • 队列更新时间;
  • 异常延迟提示。

  • [ ] Step 5: 验证签到表达

Manual:

  • 到达事件只出现“确认签到”;
  • 不使用“系统自动帮您完成签到”文案;
  • 签到成功 fixture 后旅程节点和首页事项投影同步更新;
  • 队列事件只更新候诊,不改变挂号和签到事实。

  • [ ] Step 6: 运行测试并确认通过

  • [ ] Step 7: Commit/checkpoint

    git add patient-service-desk-v3.*
    git commit -m "feat: add event-driven check-in journey"
    

Task 8: 补齐五导航和系统状态

Files:

  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css

  • [ ] Step 1: 实现就医总览

分组:

待办理
进行中
已完成
需要人工处理

每组至少展示一个真实演示任务。

  • Step 2: 实现健康档案

包含:

  • 检验报告;
  • 检查报告;
  • 门诊记录;
  • 处方与用药;
  • 过敏史;
  • 授权记录。

报告支持“全部、异常、未解读”三个筛选状态。

  • Step 3: 实现我的

包含:

  • 就诊人管理;
  • 授权中心;
  • 消息设置;
  • 常用院区;
  • 医保和支付;
  • 电子票据;
  • 人工客服;
  • 关于 AI。

  • [ ] Step 4: 实现消息中心

消息按事件类型区分:

  • 报告;
  • 就医;
  • 缴费;
  • 随访;
  • 系统。

点击消息必须定位到对应 PatientWorkItemJourneyProjection;如涉及当前 Agent 会话,再引用单一 activeAgentTaskProjection,不得直接执行命令。

  • Step 5: 实现系统状态

覆盖:

  • 初始加载;
  • AI 解读加载;
  • 空状态;
  • 断网;
  • 服务降级;
  • 查询失败;
  • Command 失败;
  • Command UNKNOWN。

  • [ ] Step 6: 手工回归

Expected:

  • 五个导航均可进入和返回;
  • 所有空状态有下一步;
  • 断网不清空本地任务展示;
  • 无患者可见 Mock、BLE、NFC、L0/L1 或 traceId;
  • 技术信息只在演示控制器中可见。

  • [ ] Step 7: Commit/checkpoint

    git add patient-service-desk-v3.js patient-service-desk-v3.css
    git commit -m "feat: complete patient desk navigation and states"
    

Task 9: 建设方案演示控制器

Files:

  • Modify: patient-service-desk-v3.html
  • Modify: patient-service-desk-v3.js
  • Modify: patient-service-desk-v3.css
  • Test: patient-service-desk-v3.test.mjs

  • [ ] Step 1: 定义八个演示场景

    export const demoScenarios = [
    "EMPTY_HOME",
    "REPORT_ARRIVED",
    "REPORT_INTERPRETED",
    "REGISTRATION_QUERY",
    "COMMAND_PREPARED",
    "APPOINTMENT_CREATED",
    "CHECKIN_AVAILABLE",
    "QUEUE_UPDATED"
    ];
    
  • [ ] Step 2: 写场景重置测试

    import { loadScenario } from "./patient-service-desk-v3.state.mjs";
    
    test("loading scenario resets previous state deterministically", () => {
    const first = loadScenario(createInitialState(), "REPORT_ARRIVED");
    const second = loadScenario(first, "EMPTY_HOME");
    
    assert.equal(second.patientWorkItems.length, 0);
    assert.equal(second.journeyProjections.length, 0);
    assert.equal(second.appliedProjectionFixtureIds.length, 0);
    assert.equal(second.activeAgentTaskProjection, null);
    assert.equal(second.activeTab, "home");
    });
    
  • [ ] Step 3: 实现场景控制器

控制器位于手机框外,仅用于方案演示:

  • 上一步;
  • 下一步;
  • 场景列表;
  • 重置;
  • 切换 390×844 / 430×932 视口;
  • 显示当前 fixture、activeAgentTaskProjectionPatientWorkItemcommandProjection 摘要;
  • 隐藏控制器进入纯净演示。

  • [ ] Step 4: 实现键盘快捷键

    ← 上一个场景
    → 下一个场景
    R 重置
    P 隐藏/显示控制器
    

输入框获得焦点时不得响应场景快捷键。

  • Step 5: 运行测试并执行主故事线

主故事线:

首页
→ 报告到达
→ 授权
→ AI 解读
→ 推荐复诊
→ 自然语言查号
→ Command 确认
→ 挂号成功
→ 到院签到
→ 候诊更新

Expected: 每一步状态可重复、可重置、无残留。 所有场景只装载 fixture/projection,不伪装为服务端事实或生产契约。

  • [ ] Step 6: Commit/checkpoint

    git add patient-service-desk-v3.*
    git commit -m "feat: add patient desk presentation controls"
    

Task 10: 接入 Open Design 索引和 Artifact

Files:

  • Copy: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.html to Open Design 发布目录
  • Copy: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.css to Open Design 发布目录
  • Copy: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.js to Open Design 发布目录
  • Copy: docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.state.mjs to Open Design 发布目录
  • Copy: docs/prototypes/patient-service-desk-v3/assets/doctor.png to Open Design 发布目录
  • Create: /Users/destiny/Library/Application Support/Open Design/namespaces/release-stable/data/projects/b7a08855-87bd-493c-92a4-31655a384570/patient-service-desk-v3.html.artifact.json
  • Modify: /Users/destiny/Library/Application Support/Open Design/namespaces/release-stable/data/projects/b7a08855-87bd-493c-92a4-31655a384570/index.html

  • [ ] Step 1: 创建 Artifact 元数据

先发布版本化源码:

OPEN_DESIGN="/Users/destiny/Library/Application Support/Open Design/namespaces/release-stable/data/projects/b7a08855-87bd-493c-92a4-31655a384570"
cp patient-service-desk-v3.html "$OPEN_DESIGN/"
cp patient-service-desk-v3.css "$OPEN_DESIGN/"
cp patient-service-desk-v3.js "$OPEN_DESIGN/"
cp patient-service-desk-v3.state.mjs "$OPEN_DESIGN/"
cp assets/doctor.png "$OPEN_DESIGN/doctor.png"
{
  "version": 1,
  "kind": "html",
  "title": "patient-service-desk-v3.html",
  "entry": "patient-service-desk-v3.html",
  "renderer": "html",
  "status": "complete",
  "exports": ["html", "pdf", "zip"],
  "createdAt": "2026-07-02T00:00:00.000Z",
  "updatedAt": "2026-07-02T00:00:00.000Z",
  "metadata": {
    "inferred": false,
    "reconciled": true
  }
}
  • Step 2: 更新索引

在核心页面最前方增加:

<a href="patient-service-desk-v3.html"
   class="screen-card patient-desk-card">
  <div class="screen-card-num">V3 · Mobile</div>
  <div class="screen-card-title">患者服务台</div>
  <div class="screen-card-desc">
    事件驱动的手机端小程序/H5方案演示:报告解读、自然语言挂号、
    Command确认、到院签到和候诊更新。
  </div>
  <span class="screen-card-link">打开</span>
</a>
  • Step 3: 验证索引入口

Expected:

  • Open Design 能识别新 Artifact;
  • 索引点击可进入 v3;
  • 返回索引可再次打开;
  • 原有 v1/v2 原型不受影响。

  • [ ] Step 4: 记录发布校验

在实施记录中保存:

Open Design 项目 ID:
b7a08855-87bd-493c-92a4-31655a384570

发布入口:
patient-service-desk-v3.html

索引入口:
index.html

Task 11: 视觉、交互和无障碍 QA

Files:

  • Modify: patient-service-desk-v3.html
  • Modify: patient-service-desk-v3.css
  • Modify: patient-service-desk-v3.js

  • [ ] Step 1: 运行状态测试

Run:

node --test patient-service-desk-v3.test.mjs

Expected: 全部 PASS。

  • Step 2: 检查两个手机视口

检查:

390 × 844
430 × 932

Expected:

  • 无横向滚动;
  • 底部导航不遮挡内容;
  • 弹层不超出视口;
  • 主按钮在安全区内;
  • 文本不截断。

  • [ ] Step 3: 检查交互

逐项验证:

  • 就诊人切换;
  • 五导航;
  • 首页任务主次操作;
  • 报告授权同意和拒绝;
  • 报告详情展开;
  • AI 输入;
  • 候选选择;
  • Command 确认和拒绝;
  • UNKNOWN 禁止重复;
  • 消息定位任务;
  • 场景控制器;
  • 断网恢复。

  • [ ] Step 4: 检查视觉

  • 医院品牌信息清晰;

  • 第一屏只有一个最强主操作;

  • 深蓝用于关键操作;

  • 青色用于 AI 和进行中;

  • 红色只用于高风险和失败;

  • 不使用 Emoji;

  • 使用 Lucide 图标;

  • 医生图片不拉伸、不遮挡任务;

  • 圆角、间距和阴影一致。

  • [ ] Step 5: 检查无障碍

  • 所有按钮有可理解名称;

  • 触控区域至少 44×44px;

  • 焦点状态可见;

  • 对话框打开后焦点进入弹层;

  • 对话框关闭后焦点返回触发按钮;

  • 状态不能只依赖颜色;

  • 动画遵守 prefers-reduced-motion

  • [ ] Step 6: 检查患者语言

仓库全文检索原型文件,必须不存在患者可见的:

Mock
traceId
BLE
NFC
L0
L1
cardKey
cardData
nextCard

演示控制器的技术状态摘要可以包含 fixture 以及 TaskState/Command 的只读 projection。

  • Step 7: 最终回归

从空状态完整演示:

报告到达
→ 授权
→ 解读
→ 复诊建议
→ 查询号源
→ 确认挂号
→ 到院
→ 确认签到
→ 候诊

Expected:

  • 无断链;
  • 无重复业务动作;
  • 所有状态可重置;
  • Card 隐藏后文本仍能说明结果;
  • 首页、就医和消息引用同一组患者服务台聚合投影;AI 仅引用单一活动 activeAgentTaskProjection

  • [ ] Step 8: Final checkpoint

    git add patient-service-desk-v3.*
    git commit -m "test: verify patient service desk prototype"
    

5. 验证命令汇总

cd /Users/destiny/dev/emoon/emoon-backend/docs/prototypes/patient-service-desk-v3
node --test patient-service-desk-v3.test.mjs
rg -n \
  "Mock|traceId|BLE|NFC|L0|L1|cardKey|cardData|nextCard" \
  /Users/destiny/dev/emoon/emoon-backend/docs/prototypes/patient-service-desk-v3/patient-service-desk-v3.*
git -C /Users/destiny/dev/emoon/emoon-backend diff --check

6. 完成标准

  1. Open Design 索引能打开 v3 患者服务台。
  2. 390×844 和 430×932 两个视口均无布局破损。
  3. 八个演示场景可独立加载和重置。
  4. 报告主线、挂号主线和到院主线能够连续演示。
  5. 同一会话最多一个活动 activeAgentTaskProjection;首页多事项使用 PatientWorkItem/JourneyProjection,不依赖 Card 类型推进。
  6. 报告解读前必须授权。
  7. L2 Command projection 严格覆盖 PREPARED → AWAITING_CONFIRMATION → EXECUTING → SUCCEEDED|FAILED|UNKNOWN 以及 REJECTED/EXPIRED;L1 明确交互授权后允许 PREPARED → EXECUTING
  8. 位置 fixture 不能自动完成签到,且签到风险等级标记为待后续专题定级。
  9. Card 隐藏或渲染失败不阻塞文本和患者服务台投影。
  10. Node 状态测试全部通过。
  11. 原有 v1/v2 原型保持不变,仅用于历史对比,不代表生产双轨。
  12. 设计文档根据最终原型更新为 reviewing
  13. 文本与 Presentation 同源于 ResultSnapshot 投影;版本冲突或过期必须 进入重新查询恢复态。
  14. 挂号链覆盖候选选择、快照校验、实时复核、L1 锁号、服务端 TaskState 更新投影和 L2 最终挂号二次确认。
  15. 客户端不创建或修改服务端 TaskState、ResultSnapshot 或 CommandInstance。

7. 执行记录

执行日期:2026-07-03

Open Design 项目 ID:
b7a08855-87bd-493c-92a4-31655a384570

发布入口:
patient-service-desk-v3.html

索引入口:
index.html

状态测试:
78/78 PASS

已完成 Artifact 元数据、索引首位入口、版本化源码和医生素材发布;发布副本与仓库 源码哈希一致。内置浏览器因本地 file:// URL 安全策略拒绝加载 Open Design 目录,未绕过该策略,双视口真实截图验收保留为外部限制;双视口尺寸、横向滚动、 44px 触控、焦点、弹层和减弱动画要求已由静态契约及状态测试覆盖。