patient-service-desk-v3.js 94 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649
  1. import {
  2. applyCandidateSelectionResponseFixture,
  3. applyCommandProjectionFixture,
  4. applyReportInterpretationProjectionFixture,
  5. applyServerProjectionFixture,
  6. buildCandidateSelectionInteraction,
  7. buildCommandConfirmationRequest,
  8. buildCommandRejectionRequest,
  9. closeReportView,
  10. createInitialState,
  11. evaluateReportInterpretationGate,
  12. grantConsent,
  13. hasValidReportConsent,
  14. loadScenario,
  15. openReportDetails,
  16. rejectConsent,
  17. requestReportInterpretation,
  18. revokeConsent,
  19. selectHomeTasks,
  20. selectReports,
  21. selectValidReportResult,
  22. submitReportFeedback,
  23. submitInteraction,
  24. } from "./patient-service-desk-v3.state.mjs";
  25. export const navItems = Object.freeze([
  26. { id: "home", label: "首页", icon: "house" },
  27. { id: "care", label: "就医", icon: "stethoscope" },
  28. { id: "assistant", label: "AI助手", icon: "sparkles" },
  29. { id: "records", label: "健康档案", icon: "folder-heart" },
  30. { id: "profile", label: "我的", icon: "user-round" },
  31. ]);
  32. export const demoScenarioGroups = Object.freeze({
  33. REPORT_CLOSURE: Object.freeze([
  34. "REPORT_ARRIVED",
  35. "REPORT_DETAIL",
  36. "REPORT_CONSENT",
  37. "REPORT_GENERATING",
  38. "REPORT_READY",
  39. "REPORT_NEED_REVIEW",
  40. "REPORT_CRITICAL",
  41. "REPORT_INVALIDATED",
  42. ]),
  43. CARE_CLOSURE: Object.freeze([
  44. "EMPTY_HOME",
  45. "REGISTRATION_QUERY",
  46. "COMMAND_PREPARED",
  47. "APPOINTMENT_CREATED",
  48. "CHECKIN_AVAILABLE",
  49. "QUEUE_UPDATED",
  50. ]),
  51. });
  52. export const demoScenarios = Object.freeze(
  53. Object.values(demoScenarioGroups).flat(),
  54. );
  55. const scenarioLabels = {
  56. EMPTY_HOME: "空闲首页",
  57. REPORT_ARRIVED: "报告到达",
  58. REPORT_DETAIL: "报告详情",
  59. REPORT_CONSENT: "报告授权",
  60. REPORT_GENERATING: "解读生成中",
  61. REPORT_READY: "报告已解读",
  62. REPORT_NEED_REVIEW: "报告需复核",
  63. REPORT_CRITICAL: "报告危急值",
  64. REPORT_INVALIDATED: "报告已失效",
  65. REGISTRATION_QUERY: "自然语言查号",
  66. COMMAND_PREPARED: "挂号待确认",
  67. APPOINTMENT_CREATED: "挂号成功",
  68. CHECKIN_AVAILABLE: "到院可签到",
  69. QUEUE_UPDATED: "候诊更新",
  70. };
  71. const homeServices = Object.freeze([
  72. { label: "预约挂号", icon: "calendar-plus" },
  73. { label: "智能导诊", icon: "scan-heart" },
  74. { label: "报告查询", icon: "file-chart-column" },
  75. { label: "门诊缴费", icon: "wallet-cards" },
  76. { label: "检查预约", icon: "clipboard-clock" },
  77. { label: "住院服务", icon: "bed" },
  78. { label: "院内导航", icon: "map-pinned" },
  79. { label: "在线问诊", icon: "messages-square" },
  80. ]);
  81. const actionLabels = {
  82. INTERPRET_REPORT: "AI 解读报告",
  83. VIEW_REPORT: "查看原报告",
  84. CONFIRM_CHECKIN: "确认签到",
  85. VIEW_ROUTE: "查看路线",
  86. VIEW_QUEUE: "查看候诊",
  87. };
  88. const actionTokens = {
  89. INTERPRET_REPORT: "interpret-report",
  90. VIEW_REPORT: "view-report",
  91. CONFIRM_CHECKIN: "open-checkin-confirmation",
  92. VIEW_ROUTE: "open-route",
  93. VIEW_QUEUE: "open-queue",
  94. };
  95. const statusPresentation = {
  96. ACTION_REQUIRED: {
  97. className: "action-required",
  98. icon: "circle-alert",
  99. label: "需要处理",
  100. },
  101. IN_PROGRESS: {
  102. className: "in-progress",
  103. icon: "loader-circle",
  104. label: "进行中",
  105. },
  106. UPCOMING: {
  107. className: "upcoming",
  108. icon: "calendar-clock",
  109. label: "即将开始",
  110. },
  111. COMPLETED: {
  112. className: "completed",
  113. icon: "circle-check-big",
  114. label: "已完成",
  115. },
  116. };
  117. const unknownStatusPresentation = {
  118. className: "unknown",
  119. icon: "circle-help",
  120. label: "状态待确认",
  121. };
  122. const escapeHtml = (value) => String(value ?? "")
  123. .replaceAll("&", "&")
  124. .replaceAll("<", "&lt;")
  125. .replaceAll(">", "&gt;")
  126. .replaceAll('"', "&quot;")
  127. .replaceAll("'", "&#039;");
  128. const getActionLabel = (action) => (
  129. typeof action === "string" && Object.hasOwn(actionLabels, action)
  130. ? actionLabels[action]
  131. : null
  132. );
  133. const renderActionAttributes = (action, task) => {
  134. const token = typeof action === "string" && Object.hasOwn(actionTokens, action)
  135. ? actionTokens[action]
  136. : null;
  137. if (!token) {
  138. return "";
  139. }
  140. if (typeof task?.reportId === "string") {
  141. return ` data-action="${token}" data-report-id="${escapeHtml(task.reportId)}"`;
  142. }
  143. if (typeof task?.appointmentId === "string") {
  144. return ` data-action="${token}" data-appointment-id="${escapeHtml(task.appointmentId)}"`;
  145. }
  146. return "";
  147. };
  148. const getStatusPresentation = (status) => (
  149. typeof status === "string" && Object.hasOwn(statusPresentation, status)
  150. ? statusPresentation[status]
  151. : unknownStatusPresentation
  152. );
  153. const getShellElements = (root) => {
  154. if (!root?.getElementById) {
  155. return null;
  156. }
  157. const appHeader = root.getElementById("appHeader");
  158. const appView = root.getElementById("appView");
  159. const bottomNav = root.getElementById("bottomNav");
  160. return appHeader && appView && bottomNav
  161. ? { appHeader, appView, bottomNav }
  162. : null;
  163. };
  164. const renderIcons = () => {
  165. const lucide = globalThis.lucide;
  166. if (lucide?.createIcons) {
  167. lucide.createIcons({ attrs: { "aria-hidden": "true" } });
  168. }
  169. };
  170. const patientDisplayNames = {
  171. "patient-zhang-san": "张先生",
  172. "patient-li-na": "李女士",
  173. };
  174. const patientDisplayName = (state) => (
  175. patientDisplayNames[state?.activePatientId] ?? "当前就诊人"
  176. );
  177. const renderHeader = (state) => {
  178. const messages = Array.isArray(state?.messages) ? state.messages : [];
  179. const unreadCount = messages.filter((message) => message?.unread).length;
  180. const messageLabel = unreadCount > 0
  181. ? `消息,${unreadCount} 条未读`
  182. : "消息";
  183. return `
  184. <div class="hospital-context">
  185. <span class="hospital-logo" aria-hidden="true">
  186. <i data-lucide="hospital"></i>
  187. </span>
  188. <div class="hospital-context__copy">
  189. <p class="app-kicker">空海医院</p>
  190. <p class="campus-label">当前院区 · 空海医院本院</p>
  191. </div>
  192. <button class="icon-button" type="button" aria-label="${messageLabel}" data-action="open-messages">
  193. <i data-lucide="bell"></i>
  194. ${unreadCount > 0 ? `<span class="unread-badge" aria-hidden="true">${unreadCount}</span>` : ""}
  195. </button>
  196. <button class="icon-button" type="button" aria-label="隐私设置">
  197. <i data-lucide="shield-check"></i>
  198. </button>
  199. </div>
  200. <button class="patient-switcher" type="button" aria-label="切换当前就诊人" data-action="switch-patient">
  201. <span>当前就诊人</span>
  202. <strong>${patientDisplayName(state)}</strong>
  203. <i data-lucide="chevron-down"></i>
  204. </button>
  205. `;
  206. };
  207. const journeyStages = [
  208. { stage: "WAITING_ARRIVAL", label: "到院", time: "09:10" },
  209. { stage: "CHECKIN_AVAILABLE", label: "签到", time: "09:20" },
  210. { stage: "QUEUE_WAITING", label: "候诊", time: "09:30" },
  211. { stage: "VISIT_IN_PROGRESS", label: "医生接诊", time: "10:05" },
  212. { stage: "POST_VISIT", label: "诊后服务", time: "10:25" },
  213. ];
  214. const renderJourneyProjection = (journey) => {
  215. const activeIndex = Math.max(
  216. 0,
  217. journeyStages.findIndex(({ stage }) => stage === journey.stage),
  218. );
  219. const start = Math.max(
  220. 0,
  221. Math.min(activeIndex - 1, journeyStages.length - 3),
  222. );
  223. const visibleStages = journeyStages.slice(start, start + 3);
  224. return `
  225. <article class="journey-card">
  226. <div class="journey-card__heading">
  227. <div>
  228. <strong>${escapeHtml(journey.title)}</strong>
  229. <p>${escapeHtml(journey.location ?? journey.room ?? "院内行程持续更新")}</p>
  230. </div>
  231. ${journey.stage === "QUEUE_WAITING" ? `
  232. <button class="text-button" type="button" data-action="open-queue" data-appointment-id="${escapeHtml(journey.appointmentId)}">
  233. 查看候诊
  234. </button>
  235. ` : ""}
  236. </div>
  237. <ol class="journey-steps">
  238. ${visibleStages.map((node) => `
  239. <li class="${node.stage === journey.stage ? "is-current" : ""}">
  240. <time>${node.time}</time>
  241. <span>${node.label}</span>
  242. </li>
  243. `).join("")}
  244. </ol>
  245. </article>
  246. `;
  247. };
  248. export const renderHome = (state) => {
  249. const tasks = selectHomeTasks(state);
  250. const journeys = Array.isArray(state?.journeyProjections)
  251. ? state.journeyProjections
  252. : [];
  253. const primaryTaskIndex = tasks.findIndex(
  254. ({ primaryAction, status }) => (
  255. status === "ACTION_REQUIRED" || status === "IN_PROGRESS"
  256. ) && Boolean(getActionLabel(primaryAction)),
  257. );
  258. const activeTaskCount = tasks.filter(
  259. ({ status }) => status === "ACTION_REQUIRED" || status === "IN_PROGRESS",
  260. ).length;
  261. return `
  262. <section class="ai-welcome" aria-labelledby="welcomeTitle">
  263. <div>
  264. <p class="section-eyebrow">AI 就医助手</p>
  265. <h1 id="welcomeTitle">${patientDisplayName(state)},上午好</h1>
  266. <p>${activeTaskCount > 0 ? `有 ${activeTaskCount} 项就医事项需要关注` : "今天暂无待处理的就医事项"}</p>
  267. </div>
  268. <img class="doctor-avatar" src="./assets/doctor.png" alt="空海医院 AI 医生助手" />
  269. <button class="assistant-input" type="button" data-action="open-assistant">
  270. <i data-lucide="sparkles"></i>
  271. <span>问病情、查报告、找医生、办就医服务</span>
  272. <i data-lucide="arrow-up-right"></i>
  273. </button>
  274. </section>
  275. <section class="home-section" aria-labelledby="tasksTitle">
  276. <div class="section-heading">
  277. <div>
  278. <p class="section-eyebrow">就医任务</p>
  279. <h2 id="tasksTitle">现在要做</h2>
  280. </div>
  281. </div>
  282. <div class="task-list">
  283. ${tasks.length === 0 ? `
  284. <div class="empty-card">
  285. <i data-lucide="circle-check-big"></i>
  286. <div>
  287. <strong>今天没有待处理事项</strong>
  288. <p>新的报告、缴费或预约提醒会显示在这里。</p>
  289. </div>
  290. </div>
  291. ` : tasks.map((task, index) => {
  292. const isCompleted = task.status === "COMPLETED";
  293. const status = getStatusPresentation(task.status);
  294. const isPrimaryTask = index === primaryTaskIndex;
  295. const primaryActionLabel = getActionLabel(task.primaryAction);
  296. const secondaryActionLabel = getActionLabel(task.secondaryAction);
  297. const canAct = (
  298. task.status === "ACTION_REQUIRED"
  299. || task.status === "IN_PROGRESS"
  300. ) && Boolean(primaryActionLabel);
  301. const recommendation = isCompleted
  302. ? "无需操作"
  303. : canAct
  304. ? primaryActionLabel
  305. : task.status === "UPCOMING"
  306. ? "等待事项开始"
  307. : "确认状态与可用操作";
  308. return `
  309. <article class="task-card task-card--${status.className}">
  310. <div class="task-card__status">
  311. <i data-lucide="${status.icon}"></i>
  312. <span>${status.label}</span>
  313. </div>
  314. <h3>${escapeHtml(task.title)}</h3>
  315. <p><strong>发生了什么:</strong>${escapeHtml(task.description)}</p>
  316. <p><strong>为什么关注:</strong>${isCompleted ? "该事项已处理完成。" : "及时处理有助于顺利推进后续就医安排。"}</p>
  317. <div class="task-card__recommendation">
  318. <span>建议操作</span>
  319. <strong>${recommendation}</strong>
  320. </div>
  321. ${canAct ? `
  322. <div class="task-card__actions">
  323. <button class="button ${isPrimaryTask ? "button--primary" : "button--secondary"}" type="button"${renderActionAttributes(task.primaryAction, task)}>
  324. ${primaryActionLabel}
  325. </button>
  326. ${secondaryActionLabel ? `
  327. <button class="button button--secondary" type="button"${renderActionAttributes(task.secondaryAction, task)}>
  328. ${secondaryActionLabel}
  329. </button>
  330. ` : ""}
  331. </div>
  332. ` : ""}
  333. </article>
  334. `;
  335. }).join("")}
  336. </div>
  337. </section>
  338. <section class="home-section" aria-labelledby="journeyTitle">
  339. <div class="section-heading">
  340. <div>
  341. <p class="section-eyebrow">院内进度</p>
  342. <h2 id="journeyTitle">今日旅程</h2>
  343. </div>
  344. </div>
  345. ${journeys.length > 0 ? journeys.map(renderJourneyProjection).join("") : `
  346. <div class="journey-empty">
  347. <i data-lucide="route"></i>
  348. <div>
  349. <strong>今天暂无院内行程</strong>
  350. <p>挂号或检查安排确认后,将展示前后步骤和当前节点。</p>
  351. </div>
  352. </div>
  353. `}
  354. </section>
  355. <section class="home-section" aria-labelledby="servicesTitle">
  356. <div class="section-heading">
  357. <div>
  358. <p class="section-eyebrow">快捷入口</p>
  359. <h2 id="servicesTitle">常用服务</h2>
  360. </div>
  361. </div>
  362. <div class="service-grid">
  363. ${homeServices.map(({ label, icon }) => `
  364. <button class="service-entry" type="button">
  365. <span><i data-lucide="${icon}"></i></span>
  366. <strong>${label}</strong>
  367. </button>
  368. `).join("")}
  369. </div>
  370. </section>
  371. <section class="home-section care-section" aria-labelledby="careTitle">
  372. <div class="section-heading">
  373. <div>
  374. <p class="section-eyebrow">健康提醒</p>
  375. <h2 id="careTitle">主动关怀</h2>
  376. </div>
  377. </div>
  378. <div class="care-empty">
  379. <i data-lucide="heart-handshake"></i>
  380. <div>
  381. <strong>暂无新的主动关怀提醒</strong>
  382. <p>用药、复诊与检查准备提醒会在确认后展示。</p>
  383. </div>
  384. </div>
  385. </section>
  386. `;
  387. };
  388. const candidateToken = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
  389. const renderAssistantMessages = (messages) => (
  390. Array.isArray(messages)
  391. ? messages
  392. .filter((message) => (
  393. message
  394. && typeof message === "object"
  395. && (message.role === "user" || message.role === "assistant")
  396. ))
  397. .map(({ role, text }) => `
  398. <article class="assistant-message assistant-message--${role}">
  399. <strong>${role === "user" ? "你" : "AI 助手"}</strong>
  400. <p>${escapeHtml(text)}</p>
  401. </article>
  402. `).join("")
  403. : ""
  404. );
  405. export const renderAssistant = (state) => {
  406. const task = state?.activeAgentTaskProjection;
  407. const result = typeof task?.activeResultRef === "string"
  408. ? state?.resultSnapshotProjections?.[task.activeResultRef]
  409. : null;
  410. const items = task?.stage === "CANDIDATE_SELECTION"
  411. && result?.resultRef === task.activeResultRef
  412. && Array.isArray(result?.items)
  413. ? result.items
  414. : [];
  415. const candidates = items.filter((item) => (
  416. item
  417. && typeof item === "object"
  418. && typeof item.candidateId === "string"
  419. && candidateToken.test(item.candidateId)
  420. ));
  421. const hasTask = task?.type === "REGISTRATION";
  422. return `
  423. <section class="assistant-view" aria-labelledby="assistantTitle">
  424. <header class="assistant-view__header">
  425. <button class="text-button" type="button" data-action="go-home">
  426. <i data-lucide="arrow-left"></i>返回首页
  427. </button>
  428. <div>
  429. <p class="section-eyebrow">自然语言就医服务</p>
  430. <h1 id="assistantTitle">AI 助手</h1>
  431. </div>
  432. ${hasTask ? `
  433. <button class="text-button" type="button" data-action="return-current-task">返回当前任务</button>
  434. ` : ""}
  435. </header>
  436. <section class="assistant-scenarios" aria-labelledby="scenarioTitle">
  437. <h2 id="scenarioTitle">场景建议</h2>
  438. <button type="button" data-action="open-report-center" data-filter="ALL">帮我看报告</button>
  439. <button type="button" data-action="submit-registration-demo">我想挂明天神经内科李明主任的号</button>
  440. <button type="button">智能导诊</button>
  441. <button type="button">人工服务</button>
  442. </section>
  443. <div class="assistant-thread" aria-live="polite">
  444. ${renderAssistantMessages(state?.messages)}
  445. ${task?.stage === "REQUERY_REQUIRED" ? `
  446. <aside class="assistant-recovery">
  447. <strong>REQUERY_REQUIRED</strong>
  448. <p>旧候选已失效,请重新查询后再选择。</p>
  449. <button class="button button--secondary" type="button" data-action="submit-registration-demo">重新查询</button>
  450. </aside>
  451. ` : ""}
  452. ${result ? `
  453. <section class="assistant-result" aria-labelledby="slotResultTitle">
  454. <h2 id="slotResultTitle">可选号源</h2>
  455. <p class="assistant-answer">${escapeHtml(result.text)}</p>
  456. ${candidates.length > 0 ? `
  457. <div class="slot-presentation">
  458. ${candidates.map((item) => `
  459. <button class="slot-candidate" type="button" data-action="select-candidate" data-candidate-id="${item.candidateId}">
  460. <strong>${escapeHtml(item.visitDate)} ${escapeHtml(item.timePeriod)} ${escapeHtml(item.visitTime)}</strong>
  461. <span>${escapeHtml(item.departmentName)} · ${escapeHtml(item.doctorName)} ${escapeHtml(item.doctorTitle)}</span>
  462. <span>余号 ${escapeHtml(item.remainingSlots)} · 挂号费 ${escapeHtml(item.fee)}</span>
  463. </button>
  464. `).join("")}
  465. </div>
  466. ` : ""}
  467. </section>
  468. ` : ""}
  469. ${task?.stage === "AWAITING_REGISTRATION_CONFIRMATION" ? `
  470. <aside class="assistant-lock-success">
  471. <strong>锁号成功</strong>
  472. <p>号源已临时锁定,已收到服务端二次确认投影。</p>
  473. </aside>
  474. ` : ""}
  475. </div>
  476. <div class="assistant-composer">
  477. <label class="sr-only" for="assistantComposerInput">输入就医需求</label>
  478. <input id="assistantComposerInput" type="text" placeholder="描述你想办理的就医服务" />
  479. <button type="button" data-action="submit-assistant-text" aria-label="发送">
  480. <i data-lucide="arrow-up"></i>
  481. </button>
  482. </div>
  483. </section>
  484. `;
  485. };
  486. const reportFamilyLabels = { LAB: "检验报告", EXAM: "检查报告" };
  487. const reportSourceStatusLabels = {
  488. FINAL: "已审核",
  489. PRELIMINARY: "待审核",
  490. };
  491. const reportInterpretationLabels = {
  492. NOT_REQUESTED: "尚未进行 AI 辅助解读",
  493. INTERPRETING: "AI 辅助解读中",
  494. READY: "AI 辅助解读已生成",
  495. NEED_REVIEW: "解读结果建议由医生确认",
  496. };
  497. const metricFlagLabels = { HIGH: "偏高", LOW: "偏低", NORMAL: "正常" };
  498. const abnormalMetricsOf = (report) => (
  499. Array.isArray(report?.metrics)
  500. ? report.metrics.filter((metric) => ["HIGH", "LOW"].includes(metric?.sourceFlag))
  501. : []
  502. );
  503. const renderMetric = (metric) => {
  504. const name = escapeHtml(metric?.name || "未命名指标");
  505. const value = metric?.value === null || metric?.value === undefined
  506. ? "—"
  507. : escapeHtml(metric.value);
  508. const unit = metric?.unit ? ` ${escapeHtml(metric.unit)}` : "";
  509. const range = metric?.referenceRange
  510. ? `<small>参考范围 ${escapeHtml(metric.referenceRange)}${unit}</small>`
  511. : "<small>参考范围未提供</small>";
  512. const flag = metricFlagLabels[metric?.sourceFlag] ?? "结果待确认";
  513. return `<li class="report-metric report-metric--${escapeHtml(String(metric?.sourceFlag ?? "unknown").toLowerCase())}">
  514. <span>${name}</span><strong>${value}${unit}</strong>${range}<em>${flag}</em>
  515. </li>`;
  516. };
  517. export const renderReportCenter = (state) => {
  518. const filters = {
  519. family: ["ALL", "LAB", "EXAM"].includes(state?.reportFilters?.family)
  520. ? state.reportFilters.family : "ALL",
  521. status: ["ALL", "UNREAD", "READ"].includes(state?.reportFilters?.status)
  522. ? state.reportFilters.status : "ALL",
  523. };
  524. const reports = selectReports(state, filters);
  525. return `
  526. <section class="module-page report-center" aria-labelledby="reportCenterTitle">
  527. ${pageHeader("健康档案", "报告中心", "医院原始报告和 AI 辅助解读统一查看。")}
  528. <p class="report-center__patient">当前就诊人 ${escapeHtml(patientDisplayName(state))}</p>
  529. <div class="report-filter-group" role="group" aria-label="报告类型">
  530. ${[["ALL", "全部"], ["LAB", "检验报告"], ["EXAM", "检查报告"]].map(([id, label]) => `
  531. <button type="button" data-action="set-report-family-filter" data-filter="${id}" aria-pressed="${filters.family === id}">${label}</button>
  532. `).join("")}
  533. </div>
  534. <div class="report-filter-group report-filter-group--status" role="group" aria-label="阅读状态">
  535. ${[["ALL", "全部"], ["UNREAD", "未读"], ["READ", "已读"]].map(([id, label]) => `
  536. <button type="button" data-action="set-report-status-filter" data-filter="${id}" aria-pressed="${filters.status === id}">${label}</button>
  537. `).join("")}
  538. </div>
  539. <div class="report-center__list">
  540. ${reports.length ? reports.map((report) => {
  541. const abnormalCount = abnormalMetricsOf(report).length;
  542. return `
  543. <button class="report-list-card" type="button" data-action="view-report" data-report-id="${escapeHtml(report.reportId)}">
  544. <span class="report-list-card__top"><b>${escapeHtml(reportFamilyLabels[report.family] ?? "医院报告")}</b><em>${report.readStatus === "READ" ? "已读" : "未读"}</em></span>
  545. <strong>${escapeHtml(report.title || "未命名报告")}</strong>
  546. <span>${escapeHtml(report.hospitalName || "医院信息待确认")} · ${escapeHtml(report.departmentName || "科室待确认")}</span>
  547. <time>${escapeHtml(report.issuedAt || "出具时间待确认")}</time>
  548. <span class="report-list-card__status">${escapeHtml(reportSourceStatusLabels[report.sourceStatus] ?? "报告状态待确认")}${report.family === "LAB" ? ` · ${abnormalCount ? `${abnormalCount} 项指标异常` : "未标记异常指标"}` : ""}</span>
  549. <span>${report.family === "EXAM"
  550. ? "该类型暂未开放AI辅助解读"
  551. : escapeHtml(reportInterpretationLabels[report.interpretationStatus] ?? "AI 辅助解读状态待确认")}</span>
  552. </button>`;
  553. }).join("") : `<div class="report-empty"><i data-lucide="file-search"></i><strong>暂无符合筛选条件的报告</strong><p>可以调整报告类型或阅读状态。</p></div>`}
  554. </div>
  555. </section>`;
  556. };
  557. export const renderReportDetail = (report = {}, expanded = false) => {
  558. const metrics = Array.isArray(report?.metrics) ? report.metrics : [];
  559. const abnormalMetrics = abnormalMetricsOf(report);
  560. const hiddenMetrics = metrics.filter(
  561. (metric) => !abnormalMetrics.includes(metric),
  562. );
  563. const visibleMetrics = expanded
  564. ? [...abnormalMetrics, ...hiddenMetrics]
  565. : abnormalMetrics;
  566. const reportId = report?.reportId ?? "";
  567. return `
  568. <section class="report-view" aria-labelledby="reportTitle">
  569. <button class="text-button report-view__back" type="button" data-action="open-report-center">
  570. <i data-lucide="arrow-left"></i>返回报告中心
  571. </button>
  572. <div class="report-hero">
  573. <p class="section-eyebrow">医院原始报告</p>
  574. <h1 id="reportTitle">${escapeHtml(report?.title || "未命名报告")}</h1>
  575. <p>${escapeHtml(report?.hospitalName || "医院信息待确认")} · ${escapeHtml(report?.departmentName || "科室待确认")}</p>
  576. <p>出具时间:${escapeHtml(report?.issuedAt || "待确认")}</p>
  577. <span class="report-audit"><i data-lucide="badge-check"></i>${escapeHtml(reportSourceStatusLabels[report?.sourceStatus] ?? "报告状态待确认")}</span>
  578. </div>
  579. <div class="report-notice">
  580. <i data-lucide="shield-alert"></i>
  581. <p><strong>AI 辅助解读仅供参考</strong>,报告结果需结合症状、病史并由医生判断。</p>
  582. </div>
  583. <section class="report-card" aria-labelledby="abnormalTitle">
  584. <div class="section-heading">
  585. <div>
  586. <p class="section-eyebrow">指标概览</p>
  587. <h2 id="abnormalTitle">${abnormalMetrics.length ? `${abnormalMetrics.length} 项异常指标` : "未标记异常指标"}</h2>
  588. </div>
  589. </div>
  590. <ul class="indicator-list">
  591. ${visibleMetrics.length ? visibleMetrics.map(renderMetric).join("") : "<li class=\"report-metric\"><span>暂无指标数据</span></li>"}
  592. </ul>
  593. ${hiddenMetrics.length ? `
  594. <button class="button button--secondary" type="button" data-action="toggle-report-metrics">
  595. ${expanded ? "收起全部指标" : "查看全部指标"}
  596. </button>
  597. ` : ""}
  598. </section>
  599. <div class="report-links" aria-label="报告相关入口">
  600. <button type="button" data-action="view-original-report" data-report-id="${escapeHtml(reportId)}"><i data-lucide="file-text"></i>查看原始报告</button>
  601. </div>
  602. <button class="button button--primary report-primary" type="button" data-action="interpret-report" data-report-id="${escapeHtml(reportId)}">
  603. <i data-lucide="sparkles"></i>AI 辅助解读
  604. </button>
  605. </section>
  606. `;
  607. };
  608. const safeReportText = (value, fallback) => (
  609. typeof value === "string" && value.trim()
  610. ? escapeHtml(value.trim())
  611. : fallback
  612. );
  613. export const renderExaminationReportDetail = (report = {}) => {
  614. const reportId = typeof report?.reportId === "string"
  615. ? report.reportId
  616. : "";
  617. return `
  618. <section class="report-view examination-report" aria-labelledby="examinationReportTitle">
  619. <button class="text-button report-view__back" type="button" data-action="open-report-center">
  620. <i data-lucide="arrow-left"></i>返回报告中心
  621. </button>
  622. <div class="report-hero">
  623. <p class="section-eyebrow">医院检查报告</p>
  624. <h1 id="examinationReportTitle">${safeReportText(report?.title, "检查名称待确认")}</h1>
  625. <p>检查部位:${safeReportText(report?.bodyPart, "待确认")}</p>
  626. <p>${safeReportText(report?.hospitalName, "医院信息待确认")} · ${safeReportText(report?.departmentName, "科室待确认")}</p>
  627. <p>出具时间:${safeReportText(report?.issuedAt, "待确认")}</p>
  628. </div>
  629. <section class="report-card" aria-labelledby="examinationFindingsTitle">
  630. <p class="section-eyebrow">检查所见</p>
  631. <h2 id="examinationFindingsTitle">检查所见</h2>
  632. <p>${safeReportText(report?.findings, "检查所见待确认")}</p>
  633. </section>
  634. <section class="report-card" aria-labelledby="examinationConclusionTitle">
  635. <p class="section-eyebrow">报告结论</p>
  636. <h2 id="examinationConclusionTitle">报告结论</h2>
  637. <p>${safeReportText(report?.conclusion, "报告结论待确认")}</p>
  638. </section>
  639. <section class="report-card examination-report__audit" aria-label="报告审核信息">
  640. <p><strong>报告医生:</strong>${safeReportText(report?.reportingDoctor, "报告医生待确认")}</p>
  641. <p><strong>审核状态:</strong>${safeReportText(
  642. report?.reviewStatus,
  643. reportSourceStatusLabels[report?.sourceStatus] ?? "审核状态待确认",
  644. )}</p>
  645. </section>
  646. <div class="report-links" aria-label="检查报告原件入口">
  647. <button type="button" data-action="view-original-report" data-report-id="${escapeHtml(reportId)}">
  648. <i data-lucide="images"></i>查看图像或PDF
  649. </button>
  650. </div>
  651. <div class="report-notice">
  652. <i data-lucide="info"></i>
  653. <p>该类型暂未开放AI辅助解读</p>
  654. </div>
  655. </section>
  656. `;
  657. };
  658. export const renderOriginalReport = (report = {}) => {
  659. if (report?.family === "EXAM") {
  660. return `
  661. <section class="report-view original-report examination-original" aria-labelledby="originalExaminationTitle">
  662. <button class="text-button report-view__back" type="button" data-action="view-report" data-report-id="${escapeHtml(report?.reportId)}">
  663. <i data-lucide="arrow-left"></i>返回报告详情
  664. </button>
  665. <div class="report-hero">
  666. <p class="section-eyebrow">医院检查报告原件</p>
  667. <h1 id="originalExaminationTitle">${safeReportText(report?.title, "检查名称待确认")}</h1>
  668. <p>${safeReportText(report?.originalFormat, "图像或PDF")}</p>
  669. </div>
  670. <div class="report-notice">
  671. <i data-lucide="file-check"></i>
  672. <p>原始图像或PDF由医院报告系统提供,本原型展示统一查看入口。</p>
  673. </div>
  674. </section>
  675. `;
  676. }
  677. const metrics = Array.isArray(report?.metrics) ? report.metrics : [];
  678. return `
  679. <section class="report-view original-report" aria-labelledby="originalReportTitle">
  680. <button class="text-button report-view__back" type="button" data-action="view-report" data-report-id="${escapeHtml(report?.reportId)}">
  681. <i data-lucide="arrow-left"></i>返回报告详情
  682. </button>
  683. <div class="report-hero">
  684. <p class="section-eyebrow">医院原始报告</p>
  685. <h1 id="originalReportTitle">原始报告内容</h1>
  686. <p>${escapeHtml(report?.title || "未命名报告")}</p>
  687. <p>${escapeHtml(report?.hospitalName || "医院信息待确认")} · ${escapeHtml(report?.departmentName || "科室待确认")}</p>
  688. <p>出具时间:${escapeHtml(report?.issuedAt || "待确认")}</p>
  689. </div>
  690. <section class="report-card" aria-label="原始指标">
  691. <ul class="indicator-list">
  692. ${metrics.length ? metrics.map(renderMetric).join("") : '<li class="report-metric"><span>暂无指标数据</span></li>'}
  693. </ul>
  694. </section>
  695. </section>
  696. `;
  697. };
  698. export const renderConsentOverlay = (state = {}) => {
  699. const reportId = state?.overlays?.reportId;
  700. const report = state?.reportSourceProjections?.[reportId];
  701. return `
  702. <div class="consent-backdrop" role="presentation">
  703. <section class="consent-sheet" role="dialog" aria-modal="true" aria-labelledby="consentTitle" tabindex="-1">
  704. <div class="consent-sheet__icon"><i data-lucide="shield-check"></i></div>
  705. <p class="section-eyebrow">隐私授权</p>
  706. <h2 id="consentTitle">授权 AI 辅助解释本次报告</h2>
  707. <dl class="consent-details">
  708. <div><dt>就诊人</dt><dd>${escapeHtml(patientDisplayName(state))}</dd></div>
  709. <div><dt>用途</dt><dd>仅用于这份报告的 AI 辅助解释</dd></div>
  710. <div><dt>范围</dt><dd>${escapeHtml(report?.title || "当前血常规报告")}</dd></div>
  711. <div><dt>有效期</dt><dd>至 2026-07-09 23:59</dd></div>
  712. </dl>
  713. <div class="consent-note">
  714. <p><strong>AI 辅助性质:</strong>用于帮助理解指标,不替代医生诊断或诊疗建议。</p>
  715. <p><strong>风险说明:</strong>解释可能存在遗漏或偏差,请结合症状由医生判断。</p>
  716. <p><strong>撤回方式:</strong>可随时在隐私设置中撤回,撤回后不再展示历史解释。</p>
  717. </div>
  718. <div class="consent-actions">
  719. <button class="button button--primary" type="button" data-action="grant-report-consent">同意并继续</button>
  720. <button class="button button--secondary" type="button" data-action="reject-report-consent">暂不授权</button>
  721. </div>
  722. </section>
  723. </div>
  724. `;
  725. };
  726. const commandPresentation = {
  727. PREPARED: { label: "准备中", icon: "loader-circle", tone: "pending" },
  728. AWAITING_CONFIRMATION: { label: "待确认", icon: "shield-check", tone: "warning" },
  729. EXECUTING: { label: "执行中", icon: "loader-circle", tone: "pending" },
  730. SUCCEEDED: { label: "挂号成功", icon: "circle-check-big", tone: "success" },
  731. FAILED: { label: "执行失败", icon: "circle-x", tone: "danger" },
  732. EXPIRED: { label: "已过期", icon: "clock-alert", tone: "danger" },
  733. UNKNOWN: { label: "结果确认中,请勿重复操作", icon: "circle-help", tone: "warning" },
  734. REJECTED: { label: "已暂不操作", icon: "circle-minus", tone: "muted" },
  735. };
  736. export const renderCommandOverlay = (command) => {
  737. const presentation = commandPresentation[command?.status]
  738. ?? commandPresentation.PREPARED;
  739. const details = command?.details ?? {};
  740. const statusLabel = command?.status === "SUCCEEDED" && !details.patientName
  741. ? "模拟完成"
  742. : presentation.label;
  743. const awaiting = command?.status === "AWAITING_CONFIRMATION";
  744. const canRequery = command?.status === "EXPIRED"
  745. || command?.status === "FAILED";
  746. const canClose = command?.status === "SUCCEEDED"
  747. || command?.status === "REJECTED";
  748. return `
  749. <div class="consent-backdrop command-backdrop" role="presentation">
  750. <section class="consent-sheet command-sheet command-sheet--${presentation.tone}" role="dialog" aria-modal="true" aria-labelledby="commandTitle" tabindex="-1">
  751. <div class="command-status">
  752. <i data-lucide="${presentation.icon}"></i>
  753. <strong>${statusLabel}</strong>
  754. </div>
  755. <p class="section-eyebrow">受控业务操作</p>
  756. <h2 id="commandTitle">${escapeHtml(details.operationName ?? "确认挂号")}</h2>
  757. <dl class="command-details">
  758. <div><dt>就诊人</dt><dd>${escapeHtml(details.patientName)}</dd></div>
  759. <div><dt>科室 / 医生</dt><dd>${escapeHtml(details.departmentName)} · ${escapeHtml(details.doctorName)}${escapeHtml(details.doctorTitle)}</dd></div>
  760. <div><dt>就诊时间</dt><dd>${escapeHtml(details.visitAt)}</dd></div>
  761. <div><dt>金额</dt><dd>${escapeHtml(details.fee)}</dd></div>
  762. <div><dt>有效时间</dt><dd>${escapeHtml(details.countdown ?? details.expiresAt)}</dd></div>
  763. </dl>
  764. ${command?.status === "UNKNOWN" ? `
  765. <p class="command-guidance">结果确认中,请勿重复操作。系统会继续查询最终结果。</p>
  766. ` : ""}
  767. ${command?.status === "FAILED" ? `
  768. <p class="command-guidance">本次操作未成功。请重新查询号源,不直接重试该 Command。</p>
  769. ` : ""}
  770. <div class="consent-actions">
  771. ${awaiting ? `
  772. <button class="button button--primary" type="button" data-action="confirm-command">确认挂号</button>
  773. <button class="button button--secondary" type="button" data-action="reject-command">暂不操作</button>
  774. ` : ""}
  775. ${canRequery ? `
  776. <button class="button button--secondary" type="button" data-action="requery-registration">重新查询号源</button>
  777. ` : ""}
  778. ${canClose ? `
  779. <button class="button button--primary" type="button" data-action="close-command">${command.status === "SUCCEEDED" ? "查看首页事项" : "关闭"}</button>
  780. ` : ""}
  781. </div>
  782. </section>
  783. </div>
  784. `;
  785. };
  786. export const renderReportInterpreting = () => `
  787. <section class="report-view report-loading" aria-labelledby="loadingTitle" aria-busy="true">
  788. <button class="text-button report-view__back" type="button" data-action="go-home">
  789. <i data-lucide="arrow-left"></i>返回首页
  790. </button>
  791. <div class="loading-orbit" aria-hidden="true"><i data-lucide="sparkles"></i></div>
  792. <p class="section-eyebrow">AI 辅助解释</p>
  793. <h1 id="loadingTitle">正在整理报告解读</h1>
  794. <p>正在结合参考范围整理便于理解的信息,请稍候。结果仅供辅助理解,不代表诊断结论。</p>
  795. <div class="loading-progress" role="progressbar" aria-label="报告解读处理中">
  796. <span></span>
  797. </div>
  798. <div class="loading-step"><i data-lucide="loader-circle"></i>分析仍在进行,暂不提供确定性结论</div>
  799. </section>
  800. `;
  801. const reportUnavailablePresentation = Object.freeze({
  802. OCR_REVIEW_REQUIRED: Object.freeze({
  803. title: "当前报告暂不适合自动解读",
  804. description: "报告内容需要人工核对,您仍可查看医院原始报告。",
  805. actions: Object.freeze([
  806. Object.freeze({
  807. action: "view-original-report",
  808. label: "查看原始报告",
  809. tone: "primary",
  810. }),
  811. Object.freeze({
  812. action: "contact-human-service",
  813. label: "联系人工服务",
  814. tone: "secondary",
  815. }),
  816. ]),
  817. }),
  818. CRITICAL_NOTICE: Object.freeze({
  819. title: "报告含医院危急提示",
  820. description: "请立即按医院通知联系医务人员,并优先遵循医院既有处置流程。",
  821. actions: Object.freeze([
  822. Object.freeze({
  823. action: "contact-human-service",
  824. label: "立即联系人工服务",
  825. tone: "primary",
  826. }),
  827. Object.freeze({
  828. action: "view-original-report",
  829. label: "查看原始报告",
  830. tone: "secondary",
  831. }),
  832. ]),
  833. }),
  834. AI_UNAVAILABLE: Object.freeze({
  835. title: "AI辅助解读暂不可用",
  836. description: "医院原始报告仍可正常查看,请稍后重试。",
  837. }),
  838. REPORT_INVALIDATED: Object.freeze({
  839. title: "医院已更新或撤回报告",
  840. description: "原AI辅助解释已失效。最新版本尚未同步完成,请返回报告中心查看状态。",
  841. actions: Object.freeze([
  842. Object.freeze({
  843. action: "open-report-center",
  844. label: "查看报告状态",
  845. tone: "primary",
  846. }),
  847. Object.freeze({
  848. action: "contact-human-service",
  849. label: "联系人工服务",
  850. tone: "secondary",
  851. }),
  852. ]),
  853. }),
  854. UNSUPPORTED: Object.freeze({
  855. title: "暂不支持自动解读",
  856. description: "您可以查看原始报告或联系人工服务。",
  857. }),
  858. });
  859. export const renderReportUnavailable = ({
  860. reason,
  861. reportId,
  862. serviceNotice,
  863. } = {}) => {
  864. const presentation = Object.hasOwn(reportUnavailablePresentation, reason)
  865. ? reportUnavailablePresentation[reason]
  866. : reportUnavailablePresentation.UNSUPPORTED;
  867. const safeReportId = typeof reportId === "string"
  868. && /^[A-Za-z0-9._-]+$/.test(reportId)
  869. ? reportId
  870. : "";
  871. const actions = presentation.actions ?? [
  872. {
  873. action: "view-original-report",
  874. label: presentation.primaryActionLabel ?? "查看原始报告",
  875. tone: "primary",
  876. },
  877. {
  878. action: "contact-human-service",
  879. label: "联系人工服务",
  880. tone: "secondary",
  881. },
  882. ];
  883. return `
  884. <section class="report-view report-unavailable" role="status" aria-labelledby="reportUnavailableTitle">
  885. <div class="report-unavailable__icon" aria-hidden="true"><i data-lucide="shield-alert"></i></div>
  886. <h1 id="reportUnavailableTitle">${escapeHtml(presentation.title)}</h1>
  887. <p>${escapeHtml(presentation.description)}</p>
  888. ${typeof serviceNotice === "string" && serviceNotice ? `
  889. <p class="result-service-notice" role="status">${escapeHtml(serviceNotice)}</p>
  890. ` : ""}
  891. <div class="report-unavailable__actions">
  892. ${actions.map(({ action, label, tone }) => `
  893. <button class="button button--${tone}" type="button" data-action="${escapeHtml(action)}" data-report-id="${escapeHtml(safeReportId)}">${escapeHtml(label)}</button>
  894. `).join("")}
  895. </div>
  896. </section>
  897. `;
  898. };
  899. export const renderReportServiceOverlay = (state = {}) => {
  900. const reason = state?.overlays?.reportUnavailableReason;
  901. const presentation = Object.hasOwn(reportUnavailablePresentation, reason)
  902. ? reportUnavailablePresentation[reason]
  903. : reportUnavailablePresentation.UNSUPPORTED;
  904. return `
  905. <div class="consent-backdrop report-service-backdrop" role="presentation">
  906. <section class="consent-sheet report-service-sheet" role="dialog" aria-modal="true" aria-labelledby="reportServiceTitle" tabindex="-1">
  907. <div class="consent-sheet__icon"><i data-lucide="headphones"></i></div>
  908. <p class="section-eyebrow">人工协助</p>
  909. <h2 id="reportServiceTitle">人工服务窗口</h2>
  910. <p>${escapeHtml(presentation.title)}</p>
  911. <p>${escapeHtml(presentation.description)}</p>
  912. <div class="consent-note">
  913. <p>医院服务人员可协助核对报告状态和原始内容。</p>
  914. <p>本原型仅演示服务入口,不会真实发起电话或在线会话。</p>
  915. </div>
  916. <div class="consent-actions">
  917. <button class="button button--primary" type="button" data-action="close-report-service">知道了</button>
  918. </div>
  919. </section>
  920. </div>
  921. `;
  922. };
  923. export const renderReportInterpretationResult = (result, options = {}) => {
  924. const interpretation = result?.interpretation;
  925. if (
  926. !interpretation
  927. || typeof interpretation !== "object"
  928. || Array.isArray(interpretation)
  929. ) {
  930. return "";
  931. }
  932. const highlights = Array.isArray(interpretation.highlights)
  933. ? interpretation.highlights
  934. : [];
  935. const explanations = Array.isArray(interpretation.explanations)
  936. ? interpretation.explanations
  937. : [];
  938. const commonFactors = Array.isArray(interpretation.commonFactors)
  939. ? interpretation.commonFactors
  940. : explanations.map((item) => item?.commonFactors).filter(Boolean);
  941. const canDo = Array.isArray(interpretation.canDo)
  942. ? interpretation.canDo
  943. : [];
  944. const evidence = Array.isArray(interpretation.evidence)
  945. ? interpretation.evidence
  946. : [];
  947. const reportId = typeof result?.reportId === "string" ? result.reportId : "";
  948. const reportAction = (action) => (
  949. `data-action="${action}" data-report-id="${escapeHtml(reportId)}"`
  950. );
  951. const serviceNotice = typeof options?.serviceNotice === "string"
  952. ? options.serviceNotice
  953. : "";
  954. const feedbackCategories = [
  955. ["HELPFUL", "有帮助"],
  956. ["METRIC_INACCURATE", "指标解释不准确"],
  957. ["HARD_TO_UNDERSTAND", "内容难理解"],
  958. ["DIFFERS_FROM_DOCTOR", "与医生说明不一致"],
  959. ["OTHER", "其他问题"],
  960. ];
  961. return `
  962. <section class="report-view result-view" aria-labelledby="resultTitle">
  963. <button class="text-button report-view__back" type="button" data-action="go-home">
  964. <i data-lucide="arrow-left"></i>返回首页
  965. </button>
  966. <div class="result-summary">
  967. <p class="section-eyebrow">AI 辅助解释 · 结果版本 ${escapeHtml(result.resultVersion)}</p>
  968. <h2>结果概括</h2>
  969. <h1 id="resultTitle">${escapeHtml(interpretation.headline)}</h1>
  970. <p>${escapeHtml(interpretation.summary)}</p>
  971. </div>
  972. <div class="safety-callout">
  973. <strong><i data-lucide="shield-check"></i>安全提示</strong>
  974. <p>${escapeHtml(interpretation.safety)}</p>
  975. </div>
  976. <section class="result-card">
  977. <h2>需要关注</h2>
  978. <ul>${highlights.map((item) => `
  979. <li>
  980. <span>${escapeHtml(item?.name)}</span>
  981. <strong>${escapeHtml(item?.value)}</strong>
  982. <em>${escapeHtml(item?.flag)}</em>
  983. </li>
  984. `).join("")}</ul>
  985. </section>
  986. <section class="result-card">
  987. <h2>指标解释</h2>
  988. ${explanations.map((item) => `
  989. <article class="explanation-item">
  990. <h3>${escapeHtml(item.indicator)}</h3>
  991. <p>${escapeHtml(item.explanation)}</p>
  992. </article>
  993. `).join("")}
  994. </section>
  995. <section class="result-card">
  996. <h2>常见相关因素</h2>
  997. <ul>${commonFactors.map((factor) => `<li>${escapeHtml(factor)}</li>`).join("")}</ul>
  998. <p class="result-factors-note">上述因素只是帮助理解指标的常见情况,不代表你患有相应疾病,也不能作为诊断结果。</p>
  999. </section>
  1000. <section class="result-card">
  1001. <h2>可以怎么做</h2>
  1002. <ul>${canDo.map((action) => `<li>${escapeHtml(action)}</li>`).join("")}</ul>
  1003. </section>
  1004. <section class="result-card result-evidence">
  1005. <h2>内容依据</h2>
  1006. <ul>${evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>
  1007. <p>结果版本 ${escapeHtml(result.resultVersion)} · 对应当前医院原始报告</p>
  1008. </section>
  1009. ${reportId ? `
  1010. <section class="result-card result-operations" aria-labelledby="resultOperationsTitle">
  1011. <h2 id="resultOperationsTitle">需要进一步帮助</h2>
  1012. ${serviceNotice ? `
  1013. <p class="result-service-notice" role="status">${escapeHtml(serviceNotice)}</p>
  1014. ` : ""}
  1015. <div class="result-action-grid">
  1016. <button class="button button--secondary" type="button" ${reportAction("view-original-report")}>查看原始报告</button>
  1017. <button class="button button--secondary" type="button" ${reportAction("consult-ordering-doctor")}>咨询开单医生</button>
  1018. <button class="button button--secondary" type="button" ${reportAction("online-consultation")}>在线咨询</button>
  1019. <button class="button button--secondary" type="button" ${reportAction("contact-human-service")}>联系人工服务</button>
  1020. <button class="button button--secondary" type="button" ${reportAction("open-report-feedback")}>提交反馈</button>
  1021. <button class="button button--secondary result-revoke" type="button" ${reportAction("revoke-report-consent")}>撤回授权</button>
  1022. </div>
  1023. </section>
  1024. ` : ""}
  1025. ${reportId && options?.feedbackOpen ? `
  1026. <section class="result-card report-feedback" aria-labelledby="reportFeedbackTitle">
  1027. <h2 id="reportFeedbackTitle">这次解释对你有帮助吗?</h2>
  1028. <p>请选择最符合的一项,反馈仅用于改进本次辅助解释。</p>
  1029. <div class="feedback-options">
  1030. ${feedbackCategories.map(([category, label]) => `
  1031. <button
  1032. class="button button--secondary"
  1033. type="button"
  1034. data-action="submit-report-feedback"
  1035. data-report-id="${escapeHtml(reportId)}"
  1036. data-filter="${category}"
  1037. >${label}</button>
  1038. `).join("")}
  1039. </div>
  1040. </section>
  1041. ` : ""}
  1042. ${options?.feedbackSubmitted ? `
  1043. <p class="report-feedback-confirmation" role="status">感谢反馈,已记录你的选择。</p>
  1044. ` : ""}
  1045. </section>
  1046. `;
  1047. };
  1048. export const renderQueueStatus = (state, appointmentId) => {
  1049. const queue = (Array.isArray(state?.patientWorkItems)
  1050. ? state.patientWorkItems
  1051. : []).find((item) => (
  1052. item?.type === "WAITING_QUEUE"
  1053. && item.appointmentId === appointmentId
  1054. ));
  1055. if (!queue) {
  1056. return `
  1057. <section class="report-view queue-view" aria-labelledby="queueTitle">
  1058. <button class="text-button report-view__back" type="button" data-action="go-home">
  1059. <i data-lucide="arrow-left"></i>返回首页
  1060. </button>
  1061. <div class="empty-card">
  1062. <i data-lucide="clock-3"></i>
  1063. <div>
  1064. <h1 id="queueTitle">候诊信息待更新</h1>
  1065. <p>完成签到后,排队号和预计时间会显示在这里。</p>
  1066. </div>
  1067. </div>
  1068. </section>
  1069. `;
  1070. }
  1071. const updatedAt = typeof queue.updatedAt === "string"
  1072. ? queue.updatedAt.slice(11, 16)
  1073. : "待更新";
  1074. return `
  1075. <section class="report-view queue-view" aria-labelledby="queueTitle">
  1076. <button class="text-button report-view__back" type="button" data-action="go-home">
  1077. <i data-lucide="arrow-left"></i>返回首页
  1078. </button>
  1079. <div class="queue-hero">
  1080. <p class="section-eyebrow">神经内科 · 实时候诊</p>
  1081. <h1 id="queueTitle">排队号 ${escapeHtml(queue.queueNumber ?? "待更新")}</h1>
  1082. <p>${escapeHtml(queue.room ?? "诊室待分配")}</p>
  1083. </div>
  1084. ${queue.delayed ? `
  1085. <div class="queue-delay" role="status">
  1086. <i data-lucide="triangle-alert"></i>
  1087. <span>候诊时间可能延后,请留意叫号更新。</span>
  1088. </div>
  1089. ` : ""}
  1090. <div class="queue-metrics">
  1091. <article><span>前方人数</span><strong>前方 ${escapeHtml(queue.peopleAhead ?? 0)} 人</strong></article>
  1092. <article><span>预计时间</span><strong>预计等待 ${escapeHtml(queue.estimatedMinutes ?? 0)} 分钟</strong></article>
  1093. </div>
  1094. <div class="queue-detail">
  1095. <p><i data-lucide="door-open"></i><span>当前诊室</span><strong>${escapeHtml(queue.room ?? "待分配")}</strong></p>
  1096. <p><i data-lucide="refresh-cw"></i><span>队列更新时间</span><strong>${escapeHtml(updatedAt)}</strong></p>
  1097. </div>
  1098. <button class="button button--secondary queue-route" type="button" data-action="open-route" data-appointment-id="${escapeHtml(appointmentId)}">
  1099. <i data-lucide="navigation"></i>院内导航
  1100. </button>
  1101. </section>
  1102. `;
  1103. };
  1104. export const renderRouteView = (location) => `
  1105. <section class="report-view route-view" aria-labelledby="routeTitle">
  1106. <button class="text-button report-view__back" type="button" data-action="go-home">
  1107. <i data-lucide="arrow-left"></i>返回首页
  1108. </button>
  1109. <div class="route-map" aria-hidden="true">
  1110. <i data-lucide="map-pinned"></i>
  1111. <span></span><span></span><span></span>
  1112. </div>
  1113. <div class="module-header">
  1114. <p class="section-eyebrow">院内路线</p>
  1115. <h1 id="routeTitle">${escapeHtml(location ?? "门诊三楼神经内科诊区")}</h1>
  1116. <p>从门诊大厅乘 2 号电梯至三楼,出电梯后右转约 30 米。</p>
  1117. </div>
  1118. <div class="queue-detail">
  1119. <p><i data-lucide="footprints"></i><span>预计步行</span><strong>4 分钟</strong></p>
  1120. <p><i data-lucide="accessibility"></i><span>无障碍路线</span><strong>可用</strong></p>
  1121. </div>
  1122. </section>
  1123. `;
  1124. const pageHeader = (eyebrow, title, description) => `
  1125. <div class="module-header">
  1126. <p class="section-eyebrow">${eyebrow}</p>
  1127. <h1>${title}</h1>
  1128. <p>${description}</p>
  1129. </div>
  1130. `;
  1131. const careGroupDefinitions = [
  1132. {
  1133. title: "待办理",
  1134. statuses: ["ACTION_REQUIRED", "UPCOMING"],
  1135. fallback: "门诊费用待确认",
  1136. },
  1137. {
  1138. title: "进行中",
  1139. statuses: ["IN_PROGRESS"],
  1140. fallback: "今日就医旅程",
  1141. },
  1142. {
  1143. title: "已完成",
  1144. statuses: ["COMPLETED"],
  1145. fallback: "身份信息已核验",
  1146. },
  1147. {
  1148. title: "需要人工处理",
  1149. statuses: ["MANUAL_REQUIRED"],
  1150. fallback: "人工服务随时可用",
  1151. },
  1152. ];
  1153. export const renderCareOverview = (state) => {
  1154. const items = Array.isArray(state?.patientWorkItems)
  1155. ? state.patientWorkItems.filter((item) => item && typeof item === "object")
  1156. : [];
  1157. return `
  1158. <section class="module-page" aria-labelledby="careOverviewTitle">
  1159. ${pageHeader("就医总览", "我的就医", "按办理状态集中查看门诊、检查和诊后事项。")}
  1160. <div class="care-groups">
  1161. ${careGroupDefinitions.map((group) => {
  1162. const matches = items.filter((item) => (
  1163. group.statuses.includes(item.status)
  1164. ));
  1165. const visible = matches.length > 0
  1166. ? matches
  1167. : [{ title: group.fallback, description: "进入详情查看下一步" }];
  1168. return `
  1169. <section class="care-group">
  1170. <div class="section-heading">
  1171. <h2>${group.title}</h2>
  1172. <span>${visible.length}</span>
  1173. </div>
  1174. ${visible.slice(0, 3).map((item) => `
  1175. <article class="compact-row">
  1176. <span class="compact-row__icon"><i data-lucide="clipboard-check"></i></span>
  1177. <div>
  1178. <strong>${escapeHtml(item.title)}</strong>
  1179. <p>${escapeHtml(item.description ?? "状态和可用操作持续更新")}</p>
  1180. </div>
  1181. <i data-lucide="chevron-right"></i>
  1182. </article>
  1183. `).join("")}
  1184. </section>
  1185. `;
  1186. }).join("")}
  1187. </div>
  1188. </section>
  1189. `;
  1190. };
  1191. const recordEntries = [
  1192. { id: "lab-reports", label: "检验报告", meta: "血常规 · 3 项异常", filter: "abnormal", icon: "flask-conical", action: "open-report-center", reportFamily: "LAB" },
  1193. { id: "exam-reports", label: "检查报告", meta: "影像与超声报告", filter: "all", icon: "scan-line", action: "open-report-center", reportFamily: "EXAM" },
  1194. { id: "visits", label: "门诊记录", meta: "历次接诊与诊后记录", filter: "all", icon: "notebook-tabs" },
  1195. { id: "medication", label: "处方与用药", meta: "处方、用药提醒", filter: "all", icon: "pill" },
  1196. { id: "allergies", label: "过敏史", meta: "暂未记录药物过敏", filter: "all", icon: "shield-alert" },
  1197. { id: "consents", label: "授权记录", meta: "查看和撤回数据授权", filter: "unread", icon: "file-lock-2" },
  1198. ];
  1199. export const renderRecords = (state) => {
  1200. const activeFilter = ["all", "abnormal", "unread"].includes(
  1201. state?.recordsFilter,
  1202. ) ? state.recordsFilter : "all";
  1203. const visible = activeFilter === "all"
  1204. ? recordEntries
  1205. : recordEntries.filter((entry) => entry.filter === activeFilter);
  1206. return `
  1207. <section class="module-page" aria-labelledby="recordsTitle">
  1208. ${pageHeader("健康档案", "我的健康记录", "报告、门诊记录和授权信息统一归档。")}
  1209. <div class="filter-tabs" role="group" aria-label="报告筛选">
  1210. ${[
  1211. ["all", "全部"],
  1212. ["abnormal", "异常"],
  1213. ["unread", "未解读"],
  1214. ].map(([id, label]) => `
  1215. <button type="button" data-action="set-record-filter" data-filter="${id}" aria-pressed="${activeFilter === id}">
  1216. ${label}
  1217. </button>
  1218. `).join("")}
  1219. </div>
  1220. <div class="module-list">
  1221. ${visible.map((entry) => `
  1222. <${entry.action ? "button" : "article"} class="compact-row"${entry.action ? ` type="button" data-action="${entry.action}" data-filter="${entry.reportFamily}"` : ""}>
  1223. <span class="compact-row__icon"><i data-lucide="${entry.icon}"></i></span>
  1224. <div><strong>${entry.label}</strong><p>${entry.meta}</p></div>
  1225. <i data-lucide="chevron-right"></i>
  1226. </${entry.action ? "button" : "article"}>
  1227. `).join("")}
  1228. </div>
  1229. </section>
  1230. `;
  1231. };
  1232. const profileEntries = [
  1233. ["就诊人管理", "users-round"],
  1234. ["授权中心", "shield-check"],
  1235. ["消息设置", "bell-ring"],
  1236. ["常用院区", "hospital"],
  1237. ["医保和支付", "badge-cent"],
  1238. ["电子票据", "receipt-text"],
  1239. ["人工客服", "headphones"],
  1240. ["关于 AI", "sparkles"],
  1241. ];
  1242. export const renderProfile = () => `
  1243. <section class="module-page" aria-labelledby="profileTitle">
  1244. ${pageHeader("个人中心", "我的", "管理就诊人、隐私授权和常用服务设置。")}
  1245. <div class="module-list">
  1246. ${profileEntries.map(([label, icon]) => `
  1247. <button class="compact-row compact-row--button" type="button">
  1248. <span class="compact-row__icon"><i data-lucide="${icon}"></i></span>
  1249. <strong>${label}</strong>
  1250. <i data-lucide="chevron-right"></i>
  1251. </button>
  1252. `).join("")}
  1253. </div>
  1254. </section>
  1255. `;
  1256. const messageCategories = ["报告", "就医", "缴费", "随访", "系统"];
  1257. export const renderMessages = (state) => {
  1258. const messages = Array.isArray(state?.messages)
  1259. ? state.messages.filter((message) => message && typeof message === "object")
  1260. : [];
  1261. return `
  1262. <section class="module-page" aria-labelledby="messagesTitle">
  1263. ${pageHeader("消息中心", "消息", "按服务类型定位到对应就医事项,不会直接执行办理操作。")}
  1264. <div class="message-categories" aria-label="消息类型">
  1265. ${messageCategories.map((category) => `<span>${category}</span>`).join("")}
  1266. </div>
  1267. <div class="module-list">
  1268. ${messages.length > 0 ? messages.map((message) => `
  1269. <button class="compact-row compact-row--button" type="button" data-action="open-message" data-message-id="${escapeHtml(message.id)}">
  1270. <span class="compact-row__icon"><i data-lucide="${message.type === "REPORT" ? "file-chart-column" : "bell"}"></i></span>
  1271. <div>
  1272. <strong>${escapeHtml(message.title ?? message.text ?? "服务消息")}</strong>
  1273. <p>${escapeHtml(message.createdAt ?? "刚刚更新")}</p>
  1274. </div>
  1275. <i data-lucide="chevron-right"></i>
  1276. </button>
  1277. `).join("") : `
  1278. <div class="empty-card">
  1279. <i data-lucide="inbox"></i>
  1280. <div><strong>暂无新消息</strong><p>新的报告和就医提醒会显示在这里。</p></div>
  1281. </div>
  1282. `}
  1283. </div>
  1284. </section>
  1285. `;
  1286. };
  1287. export const renderSystemState = (type, state) => {
  1288. const cachedTaskCount = Array.isArray(state?.patientWorkItems)
  1289. ? state.patientWorkItems.length
  1290. : 0;
  1291. const definitions = {
  1292. INITIAL_LOADING: ["正在加载患者服务台", "正在同步最新就医事项,请稍候。", "loader-circle"],
  1293. EMPTY: ["暂无相关内容", "可以返回首页或尝试其他服务。", "inbox"],
  1294. OFFLINE: ["网络连接不可用", `已保留 ${cachedTaskCount} 项本地任务,联网后将自动更新。`, "wifi-off"],
  1295. DEGRADED: ["部分智能服务暂不可用", "基础就医事项仍可查看,也可以联系人工服务。", "shield-alert"],
  1296. QUERY_FAILED: ["查询失败", "请稍后重新查询,旧结果不会用于继续办理。", "search-x"],
  1297. COMMAND_FAILED: ["办理失败", "本次操作未完成,请重新查询或联系人工服务。", "circle-x"],
  1298. COMMAND_UNKNOWN: ["结果确认中,请勿重复操作", "正在核验医院系统结果,可以稍后查看状态。", "circle-ellipsis"],
  1299. };
  1300. const [title, description, icon] = definitions[type] ?? definitions.EMPTY;
  1301. return `
  1302. <section class="system-state" role="status">
  1303. <span><i data-lucide="${icon}"></i></span>
  1304. <h1>${title}</h1>
  1305. <p>${description}</p>
  1306. <button class="button button--secondary" type="button" data-action="go-home">返回首页</button>
  1307. </section>
  1308. `;
  1309. };
  1310. export const renderScenarioPanel = (state) => {
  1311. const activeScenario = demoScenarios.includes(state?.demoScenario)
  1312. ? state.demoScenario
  1313. : "EMPTY_HOME";
  1314. const activeTask = state?.activeAgentTaskProjection;
  1315. const commands = Object.values(state?.commandProjections ?? {});
  1316. const latestFixture = state?.appliedProjectionFixtureIds?.at?.(-1) ?? "无";
  1317. const renderScenarioGroup = (groupId, title, scenarios) => `
  1318. <section
  1319. class="scenario-group"
  1320. aria-labelledby="${groupId}"
  1321. >
  1322. <h2 id="${groupId}">${title}</h2>
  1323. <div class="scenario-list">
  1324. ${scenarios.map((scenario) => `
  1325. <button type="button" data-action="load-scenario" data-scenario="${scenario}" aria-pressed="${scenario === activeScenario}">
  1326. <span>${String(demoScenarios.indexOf(scenario) + 1).padStart(2, "0")}</span>
  1327. ${scenarioLabels[scenario]}
  1328. </button>
  1329. `).join("")}
  1330. </div>
  1331. </section>
  1332. `;
  1333. return `
  1334. <div class="scenario-controller">
  1335. <div class="scenario-controller__header">
  1336. <div>
  1337. <p>方案演示</p>
  1338. <strong>${escapeHtml(scenarioLabels[activeScenario])}</strong>
  1339. </div>
  1340. <button type="button" data-action="toggle-scenario-panel" aria-label="隐藏演示控制器">
  1341. <i data-lucide="panel-right-close"></i>
  1342. </button>
  1343. </div>
  1344. <div class="scenario-actions">
  1345. <button type="button" data-action="previous-scenario"><i data-lucide="arrow-left"></i>上一步</button>
  1346. <button type="button" data-action="next-scenario">下一步<i data-lucide="arrow-right"></i></button>
  1347. <button type="button" data-action="reset-scenario"><i data-lucide="rotate-ccw"></i>重置</button>
  1348. </div>
  1349. <div class="scenario-groups" aria-label="演示场景">
  1350. ${renderScenarioGroup(
  1351. "reportClosureScenarios",
  1352. "报告闭环",
  1353. demoScenarioGroups.REPORT_CLOSURE,
  1354. )}
  1355. ${renderScenarioGroup(
  1356. "careClosureScenarios",
  1357. "就医闭环",
  1358. demoScenarioGroups.CARE_CLOSURE,
  1359. )}
  1360. </div>
  1361. <div class="viewport-switcher" aria-label="演示视口">
  1362. <button type="button" data-action="set-demo-viewport" data-viewport="390">390×844</button>
  1363. <button type="button" data-action="set-demo-viewport" data-viewport="430">430×932</button>
  1364. </div>
  1365. <dl class="projection-summary">
  1366. <div><dt>当前 fixture</dt><dd>${escapeHtml(latestFixture)}</dd></div>
  1367. <div><dt>Agent task</dt><dd>${escapeHtml(activeTask ? `${activeTask.type}/${activeTask.stage}` : "无")}</dd></div>
  1368. <div><dt>PatientWorkItem</dt><dd>${escapeHtml(state?.patientWorkItems?.length ?? 0)} 项</dd></div>
  1369. <div><dt>Command</dt><dd>${escapeHtml(commands.map((item) => `${item.commandId}/${item.status}`).join(", ") || "无")}</dd></div>
  1370. </dl>
  1371. <p class="scenario-shortcuts">←/→ 切换 · R 重置 · P 显示/隐藏</p>
  1372. </div>
  1373. `;
  1374. };
  1375. const renderCurrentView = (state) => {
  1376. const { reportId, reportView } = state?.overlays ?? {};
  1377. if (state?.systemState === "AI_INTERPRETING") {
  1378. return renderReportInterpreting();
  1379. }
  1380. if (state?.systemState) {
  1381. return renderSystemState(state.systemState, state);
  1382. }
  1383. if (state?.overlays?.journeyView === "CHECKIN_CONFIRMATION") {
  1384. return `
  1385. <section class="report-view checkin-view" aria-labelledby="checkinTitle">
  1386. <button class="text-button report-view__back" type="button" data-action="go-home">
  1387. <i data-lucide="arrow-left"></i>返回首页
  1388. </button>
  1389. <div class="report-hero">
  1390. <p class="section-eyebrow">到院签到</p>
  1391. <h1 id="checkinTitle">请确认本人已到达诊区</h1>
  1392. <p>签到需要患者明确确认;本页面不会根据位置自动完成签到。</p>
  1393. </div>
  1394. <div class="report-notice">
  1395. <i data-lucide="map-pin-check"></i>
  1396. <p>确认后仍以医院签到接口返回结果为准。</p>
  1397. </div>
  1398. <div class="task-card__actions">
  1399. <button class="button button--primary" type="button" data-action="submit-checkin" data-appointment-id="${escapeHtml(state.overlays.appointmentId)}">
  1400. 确认签到
  1401. </button>
  1402. <button class="button button--secondary" type="button" data-action="go-home">
  1403. 暂不签到
  1404. </button>
  1405. </div>
  1406. </section>
  1407. `;
  1408. }
  1409. if (state?.overlays?.journeyView === "ROUTE") {
  1410. const journey = state.journeyProjections?.find(
  1411. (item) => item?.appointmentId === state.overlays.appointmentId,
  1412. );
  1413. const checkin = state.patientWorkItems?.find(
  1414. (item) => (
  1415. item?.type === "CHECK_IN"
  1416. && item.appointmentId === state.overlays.appointmentId
  1417. ),
  1418. );
  1419. return renderRouteView(
  1420. journey?.location ?? checkin?.location ?? journey?.room,
  1421. );
  1422. }
  1423. if (
  1424. state?.overlays?.journeyView === "QUEUE"
  1425. && state?.overlays?.appointmentId
  1426. ) {
  1427. return renderQueueStatus(state, state.overlays.appointmentId);
  1428. }
  1429. if (reportView === "REPORT_DETAIL" && reportId) {
  1430. const report = state.reportSourceProjections?.[reportId];
  1431. return report?.family === "EXAM"
  1432. ? renderExaminationReportDetail(report)
  1433. : renderReportDetail(report, Boolean(state.overlays.reportExpanded));
  1434. }
  1435. if (reportView === "ORIGINAL_REPORT" && reportId) {
  1436. return renderOriginalReport(state.reportSourceProjections?.[reportId]);
  1437. }
  1438. if (reportView === "REPORT_UNAVAILABLE" && reportId) {
  1439. return renderReportUnavailable({
  1440. reason: state?.overlays?.reportUnavailableReason,
  1441. reportId,
  1442. serviceNotice: state?.overlays?.reportServiceNotice,
  1443. });
  1444. }
  1445. if (reportView === "INTERPRETING" && reportId) {
  1446. return renderReportInterpreting();
  1447. }
  1448. if (
  1449. reportView === "INTERPRETED"
  1450. && hasValidReportConsent(state, reportId)
  1451. ) {
  1452. return renderReportInterpretationResult(
  1453. selectValidReportResult(state, reportId),
  1454. {
  1455. feedbackOpen: state?.overlays?.reportFeedbackOpen === true,
  1456. feedbackSubmitted: state?.overlays?.reportFeedbackSubmitted === true,
  1457. serviceNotice: state?.overlays?.reportServiceNotice,
  1458. },
  1459. );
  1460. }
  1461. if (state?.activeTab === "assistant") {
  1462. return renderAssistant(state);
  1463. }
  1464. if (state?.activeTab === "care") {
  1465. return renderCareOverview(state);
  1466. }
  1467. if (state?.activeTab === "records") {
  1468. return state?.overlays?.reportView === "REPORT_CENTER"
  1469. ? renderReportCenter(state)
  1470. : renderRecords(state);
  1471. }
  1472. if (state?.activeTab === "profile") {
  1473. return renderProfile(state);
  1474. }
  1475. if (state?.activeTab === "messages") {
  1476. return renderMessages(state);
  1477. }
  1478. return renderHome(state);
  1479. };
  1480. let modalOverlayIdentity = null;
  1481. let modalOverlayDocument = null;
  1482. let consentFocusReturn = null;
  1483. let scenarioPanelVisible = true;
  1484. let demoViewport = "390";
  1485. const hasConsentDialog = (state) => (
  1486. typeof state?.overlays?.consentId === "string"
  1487. && state.overlays.consentId.startsWith("consent-ai-")
  1488. );
  1489. const hasCommandDialog = (state) => Boolean(
  1490. state?.overlays?.commandId
  1491. && state?.commandProjections?.[state.overlays.commandId]
  1492. ?.status === "AWAITING_CONFIRMATION"
  1493. );
  1494. const hasReportServiceDialog = (state) => (
  1495. state?.overlays?.reportView === "REPORT_UNAVAILABLE"
  1496. && state?.overlays?.reportServiceNotice
  1497. === "已为你打开人工服务入口。"
  1498. );
  1499. const focusModalDialog = (overlayRoot) => {
  1500. const dialog = overlayRoot?.querySelector?.('[role="dialog"]');
  1501. const firstButton = dialog?.querySelector?.("button");
  1502. (firstButton ?? dialog)?.focus?.();
  1503. };
  1504. const restoreConsentFocus = (root, elements) => {
  1505. const matchingTrigger = Array.from(
  1506. root?.querySelectorAll?.('[data-action="interpret-report"]') ?? [],
  1507. ).find((trigger) => (
  1508. trigger.dataset?.reportId === consentFocusReturn?.reportId
  1509. ));
  1510. const fallback = elements.appView.querySelector?.(
  1511. "button, [href], [tabindex]",
  1512. ) ?? elements.appView;
  1513. (matchingTrigger ?? fallback)?.focus?.();
  1514. consentFocusReturn = null;
  1515. };
  1516. export const renderAppShell = (state = createInitialState()) => {
  1517. const root = globalThis.document;
  1518. const elements = getShellElements(root);
  1519. if (!elements) {
  1520. return false;
  1521. }
  1522. if (root !== modalOverlayDocument) {
  1523. modalOverlayIdentity = null;
  1524. modalOverlayDocument = root;
  1525. }
  1526. elements.appHeader.innerHTML = renderHeader(state);
  1527. elements.appView.innerHTML = renderCurrentView(state);
  1528. elements.bottomNav.innerHTML = navItems.map(
  1529. ({ id, label, icon }) => `
  1530. <button
  1531. class="nav-item"
  1532. type="button"
  1533. data-nav-id="${id}"
  1534. data-action="navigate-tab"
  1535. data-tab-id="${id}"
  1536. aria-label="${label}"
  1537. ${state?.activeTab === id ? 'aria-current="page"' : ""}
  1538. >
  1539. <i data-lucide="${icon}"></i>
  1540. <span>${label}</span>
  1541. </button>
  1542. `,
  1543. ).join("");
  1544. const scenarioPanel = root?.getElementById?.("scenarioPanel");
  1545. const phoneShell = root?.querySelector?.(".phone-shell");
  1546. if (scenarioPanel) {
  1547. scenarioPanel.innerHTML = renderScenarioPanel(state);
  1548. scenarioPanel.hidden = !scenarioPanelVisible;
  1549. }
  1550. if (phoneShell?.dataset) {
  1551. phoneShell.dataset.viewport = demoViewport;
  1552. }
  1553. const overlayRoot = root?.getElementById?.("overlayRoot");
  1554. const hasConsentOverlay = Boolean(overlayRoot) && hasConsentDialog(state);
  1555. const activeCommand = state?.overlays?.commandId
  1556. ? state?.commandProjections?.[state.overlays.commandId]
  1557. : null;
  1558. const hasCommandOverlay = Boolean(overlayRoot) && hasCommandDialog(state);
  1559. const hasReportServiceOverlay = Boolean(overlayRoot)
  1560. && hasReportServiceDialog(state);
  1561. const nextModalIdentity = hasConsentOverlay
  1562. ? `consent:${state.overlays.consentId}`
  1563. : hasCommandOverlay
  1564. ? `command:${activeCommand.commandId}:${activeCommand.status}`
  1565. : hasReportServiceOverlay
  1566. ? `report-service:${state.overlays.reportId}`
  1567. : null;
  1568. const hasModalOverlay = Boolean(nextModalIdentity);
  1569. for (const element of [
  1570. elements.appHeader,
  1571. elements.appView,
  1572. elements.bottomNav,
  1573. scenarioPanel,
  1574. ]) {
  1575. if (!element) {
  1576. continue;
  1577. }
  1578. element.inert = hasModalOverlay;
  1579. element.setAttribute?.(
  1580. "aria-hidden",
  1581. hasModalOverlay ? "true" : "false",
  1582. );
  1583. }
  1584. if (overlayRoot) {
  1585. overlayRoot.innerHTML = hasConsentOverlay
  1586. ? renderConsentOverlay(state)
  1587. : hasCommandOverlay
  1588. ? renderCommandOverlay(activeCommand)
  1589. : hasReportServiceOverlay
  1590. ? renderReportServiceOverlay(state)
  1591. : "";
  1592. if (nextModalIdentity && nextModalIdentity !== modalOverlayIdentity) {
  1593. focusModalDialog(overlayRoot);
  1594. } else if (!nextModalIdentity && modalOverlayIdentity) {
  1595. restoreConsentFocus(root, elements);
  1596. }
  1597. }
  1598. modalOverlayIdentity = nextModalIdentity;
  1599. renderIcons();
  1600. return true;
  1601. };
  1602. let demoState = loadScenario(createInitialState(), "EMPTY_HOME");
  1603. const boundDocuments = new WeakSet();
  1604. let reportProjectionGeneration = 0;
  1605. let activeReportProjectionTimer = null;
  1606. let registrationSelectionFixtureSequence = 0;
  1607. let commandConfirmationInFlight = null;
  1608. let lastCommandConfirmationRequest = null;
  1609. let commandRejectionInFlight = null;
  1610. let lastCommandRejectionRequest = null;
  1611. export const getDemoState = () => demoState;
  1612. export const getLastCommandConfirmationRequest = () => (
  1613. lastCommandConfirmationRequest
  1614. );
  1615. export const getLastCommandRejectionRequest = () => (
  1616. lastCommandRejectionRequest
  1617. );
  1618. const simulateCandidateSelectionBackendFixture = (state, interaction) => {
  1619. registrationSelectionFixtureSequence += 1;
  1620. const task = state?.activeAgentTaskProjection;
  1621. const result = task?.activeResultRef
  1622. ? state?.resultSnapshotProjections?.[task.activeResultRef]
  1623. : null;
  1624. const now = Date.parse(state?.demoNow);
  1625. const expiresAt = Date.parse(result?.expiresAt);
  1626. const versionMatched = interaction?.resultRef === result?.resultRef
  1627. && interaction?.resultVersion === result?.resultVersion;
  1628. const notExpired = Number.isFinite(now)
  1629. && Number.isFinite(expiresAt)
  1630. && expiresAt > now;
  1631. const realtimeAvailable = Array.isArray(result?.items)
  1632. && result.items.some(
  1633. (item) => item?.candidateId === interaction?.candidateId,
  1634. );
  1635. const errorCode = !notExpired
  1636. ? "RESULT_EXPIRED"
  1637. : !versionMatched
  1638. ? "RESULT_VERSION_CONFLICT"
  1639. : !realtimeAvailable
  1640. ? "SLOT_UNAVAILABLE"
  1641. : null;
  1642. const fixtureId = `selection-response-${registrationSelectionFixtureSequence}`;
  1643. if (errorCode) {
  1644. return {
  1645. id: fixtureId,
  1646. errorCode,
  1647. validation: {
  1648. tenantMatched: true,
  1649. patientMatched: true,
  1650. sessionMatched: true,
  1651. versionMatched,
  1652. notExpired,
  1653. realtimeAvailable,
  1654. l1LockSucceeded: false,
  1655. },
  1656. };
  1657. }
  1658. const lockExpiresAt = new Date(
  1659. Math.min(now + 5 * 60 * 1000, expiresAt - 1),
  1660. ).toISOString();
  1661. return {
  1662. id: fixtureId,
  1663. outcome: "SUCCESS",
  1664. ...interaction,
  1665. validation: {
  1666. tenantMatched: true,
  1667. patientMatched: true,
  1668. sessionMatched: true,
  1669. versionMatched,
  1670. notExpired,
  1671. realtimeAvailable,
  1672. l1LockSucceeded: true,
  1673. },
  1674. lock: {
  1675. lockId: `appointment-lock-${registrationSelectionFixtureSequence}`,
  1676. expiresAt: lockExpiresAt,
  1677. },
  1678. };
  1679. };
  1680. const submitAssistantText = (state, text) => {
  1681. const normalizedText = typeof text === "string" ? text.trim() : text;
  1682. const interaction = buildCandidateSelectionInteraction(
  1683. state,
  1684. normalizedText,
  1685. );
  1686. if (
  1687. !interaction
  1688. && !(
  1689. normalizedText === "选第一个"
  1690. && state?.activeAgentTaskProjection?.type === "REGISTRATION"
  1691. )
  1692. ) {
  1693. return submitInteraction(state, normalizedText);
  1694. }
  1695. const responseFixture = simulateCandidateSelectionBackendFixture(
  1696. state,
  1697. interaction,
  1698. );
  1699. const selectedState = applyCandidateSelectionResponseFixture(
  1700. state,
  1701. responseFixture,
  1702. );
  1703. return applyRegistrationCommandBackendFixture(selectedState);
  1704. };
  1705. const applyRegistrationCommandBackendFixture = (state) => {
  1706. const task = state?.activeAgentTaskProjection;
  1707. const result = state?.resultSnapshotProjections?.[task?.activeResultRef];
  1708. const selected = result?.items?.find(
  1709. (item) => item?.candidateId === task?.selectedCandidateId,
  1710. );
  1711. if (task?.stage !== "AWAITING_REGISTRATION_CONFIRMATION" || !selected) {
  1712. return state;
  1713. }
  1714. const commands = Object.values(state?.commandProjections ?? {});
  1715. const nonTerminalStatuses = new Set([
  1716. "PREPARED",
  1717. "AWAITING_CONFIRMATION",
  1718. "EXECUTING",
  1719. "UNKNOWN",
  1720. ]);
  1721. const existing = commands.find((command) => (
  1722. command?.details?.candidateId === selected.candidateId
  1723. && nonTerminalStatuses.has(command.status)
  1724. ));
  1725. if (existing) {
  1726. return {
  1727. ...state,
  1728. overlays: { ...state.overlays, commandId: existing.commandId },
  1729. };
  1730. }
  1731. const nextSequence = commands.reduce((highest, command) => {
  1732. const sequence = Number.parseInt(
  1733. command?.commandId?.match(/^command-register-(\d+)$/)?.[1],
  1734. 10,
  1735. );
  1736. return Number.isInteger(sequence) ? Math.max(highest, sequence) : highest;
  1737. }, 0) + 1;
  1738. const commandId = `command-register-${String(nextSequence).padStart(3, "0")}`;
  1739. return applyCommandProjectionFixture(state, {
  1740. id: `command-awaiting-${commandId}`,
  1741. commandId,
  1742. version: 1,
  1743. riskLevel: "L2",
  1744. status: "AWAITING_CONFIRMATION",
  1745. details: {
  1746. operationName: "确认挂号",
  1747. patientName: "张先生",
  1748. departmentName: selected.departmentName,
  1749. doctorName: selected.doctorName,
  1750. doctorTitle: selected.doctorTitle,
  1751. visitAt: `${selected.visitDate} ${selected.visitTime}`,
  1752. fee: selected.fee,
  1753. expiresAt: task.lock?.expiresAt,
  1754. countdown: "剩余 5 分钟",
  1755. candidateId: selected.candidateId,
  1756. },
  1757. });
  1758. };
  1759. const reduceDemoAction = (
  1760. state,
  1761. action,
  1762. reportId,
  1763. candidateId,
  1764. assistantText,
  1765. appointmentId,
  1766. tabId,
  1767. filter,
  1768. messageId,
  1769. scenarioName,
  1770. viewport,
  1771. ) => {
  1772. switch (action) {
  1773. case "view-report":
  1774. return openReportDetails(state, reportId);
  1775. case "view-original-report":
  1776. return state.reportSourceProjections?.[reportId] ? {
  1777. ...state,
  1778. overlays: {
  1779. ...state.overlays,
  1780. reportId,
  1781. reportView: "ORIGINAL_REPORT",
  1782. },
  1783. } : state;
  1784. case "open-report-center": {
  1785. const reportCenterState = closeReportView(state);
  1786. return {
  1787. ...reportCenterState,
  1788. activeTab: "records",
  1789. reportFilters: {
  1790. ...reportCenterState.reportFilters,
  1791. family: ["LAB", "EXAM"].includes(filter) ? filter : "ALL",
  1792. },
  1793. overlays: {
  1794. ...reportCenterState.overlays,
  1795. reportView: "REPORT_CENTER",
  1796. },
  1797. };
  1798. }
  1799. case "set-report-family-filter":
  1800. return {
  1801. ...state,
  1802. activeTab: "records",
  1803. reportFilters: {
  1804. ...state.reportFilters,
  1805. family: ["ALL", "LAB", "EXAM"].includes(filter) ? filter : "ALL",
  1806. },
  1807. };
  1808. case "set-report-status-filter":
  1809. return {
  1810. ...state,
  1811. activeTab: "records",
  1812. reportFilters: {
  1813. ...state.reportFilters,
  1814. status: ["ALL", "UNREAD", "READ"].includes(filter) ? filter : "ALL",
  1815. },
  1816. };
  1817. case "toggle-report-metrics":
  1818. return {
  1819. ...state,
  1820. overlays: {
  1821. ...state.overlays,
  1822. reportExpanded: !state.overlays?.reportExpanded,
  1823. },
  1824. };
  1825. case "interpret-report":
  1826. return requestReportInterpretation(state, reportId);
  1827. case "grant-report-consent":
  1828. return grantConsent(state, state?.overlays?.consentId);
  1829. case "reject-report-consent":
  1830. return rejectConsent(state, state?.overlays?.consentId);
  1831. case "open-report-feedback":
  1832. return (
  1833. reportId === state?.overlays?.reportId
  1834. && state?.overlays?.reportView === "INTERPRETED"
  1835. && hasValidReportConsent(state, reportId)
  1836. && selectValidReportResult(state, reportId)
  1837. ) ? {
  1838. ...state,
  1839. overlays: {
  1840. ...state.overlays,
  1841. reportFeedbackOpen: true,
  1842. reportFeedbackSubmitted: false,
  1843. reportServiceNotice: null,
  1844. },
  1845. } : state;
  1846. case "submit-report-feedback": {
  1847. if (
  1848. reportId !== state?.overlays?.reportId
  1849. || state?.overlays?.reportView !== "INTERPRETED"
  1850. ) {
  1851. return state;
  1852. }
  1853. const feedbackState = submitReportFeedback(state, {
  1854. reportId,
  1855. category: filter,
  1856. });
  1857. return feedbackState === state ? state : {
  1858. ...feedbackState,
  1859. overlays: {
  1860. ...feedbackState.overlays,
  1861. reportFeedbackOpen: false,
  1862. reportFeedbackSubmitted: true,
  1863. reportServiceNotice: null,
  1864. },
  1865. };
  1866. }
  1867. case "consult-ordering-doctor":
  1868. case "online-consultation":
  1869. case "contact-human-service": {
  1870. if (
  1871. action === "contact-human-service"
  1872. && reportId === state?.overlays?.reportId
  1873. && state?.overlays?.reportView === "REPORT_UNAVAILABLE"
  1874. && state?.reportSourceProjections?.[reportId]
  1875. ) {
  1876. return {
  1877. ...state,
  1878. overlays: {
  1879. ...state.overlays,
  1880. reportServiceNotice: "已为你打开人工服务入口。",
  1881. },
  1882. };
  1883. }
  1884. if (
  1885. reportId !== state?.overlays?.reportId
  1886. || state?.overlays?.reportView !== "INTERPRETED"
  1887. || !hasValidReportConsent(state, reportId)
  1888. || !selectValidReportResult(state, reportId)
  1889. ) {
  1890. return state;
  1891. }
  1892. const notices = {
  1893. "consult-ordering-doctor": "已为你打开开单医生咨询入口。",
  1894. "online-consultation": "已为你打开在线咨询入口。",
  1895. "contact-human-service": "已为你打开人工服务入口。",
  1896. };
  1897. return {
  1898. ...state,
  1899. overlays: {
  1900. ...state.overlays,
  1901. reportFeedbackOpen: false,
  1902. reportServiceNotice: notices[action],
  1903. },
  1904. };
  1905. }
  1906. case "close-report-service":
  1907. return hasReportServiceDialog(state) ? {
  1908. ...state,
  1909. overlays: {
  1910. ...state.overlays,
  1911. reportServiceNotice: null,
  1912. },
  1913. } : state;
  1914. case "revoke-report-consent": {
  1915. const activeReportId = state?.overlays?.reportId;
  1916. const activeConsentId = typeof activeReportId === "string"
  1917. ? `consent-ai-${activeReportId}`
  1918. : Object.keys(state?.consents ?? {}).find(
  1919. (consentId) => state.consents[consentId]?.status === "GRANTED",
  1920. );
  1921. return revokeConsent(state, activeConsentId);
  1922. }
  1923. case "go-home": {
  1924. const homeState = closeReportView(state);
  1925. return {
  1926. ...homeState,
  1927. activeTab: "home",
  1928. overlays: {
  1929. ...homeState.overlays,
  1930. appointmentId: null,
  1931. journeyView: null,
  1932. },
  1933. };
  1934. }
  1935. case "open-checkin-confirmation":
  1936. return {
  1937. ...state,
  1938. overlays: {
  1939. ...state.overlays,
  1940. appointmentId,
  1941. journeyView: "CHECKIN_CONFIRMATION",
  1942. },
  1943. };
  1944. case "open-queue":
  1945. return {
  1946. ...state,
  1947. overlays: {
  1948. ...state.overlays,
  1949. appointmentId,
  1950. journeyView: "QUEUE",
  1951. },
  1952. };
  1953. case "open-route":
  1954. return {
  1955. ...state,
  1956. overlays: {
  1957. ...state.overlays,
  1958. appointmentId,
  1959. journeyView: "ROUTE",
  1960. },
  1961. };
  1962. case "switch-patient": {
  1963. commandConfirmationInFlight = null;
  1964. commandRejectionInFlight = null;
  1965. invalidateReportProjectionTimer();
  1966. const nextPatientId = state.activePatientId === "patient-li-na"
  1967. ? "patient-zhang-san"
  1968. : "patient-li-na";
  1969. return {
  1970. ...createInitialState({ activePatientId: nextPatientId }),
  1971. demoScenario: "EMPTY_HOME",
  1972. };
  1973. }
  1974. case "submit-checkin": {
  1975. const checkedIn = applyServerProjectionFixture(state, {
  1976. id: `checkin-completed-${appointmentId}`,
  1977. type: "CHECKIN_COMPLETED",
  1978. occurredAt: state.demoNow,
  1979. payload: {
  1980. appointmentId,
  1981. queueNumber: "A023",
  1982. room: "3F-08诊室",
  1983. },
  1984. });
  1985. return {
  1986. ...checkedIn,
  1987. overlays: {
  1988. ...checkedIn.overlays,
  1989. appointmentId,
  1990. journeyView: "QUEUE",
  1991. },
  1992. };
  1993. }
  1994. case "open-assistant":
  1995. case "return-current-task":
  1996. return {
  1997. ...closeReportView(state),
  1998. activeTab: "assistant",
  1999. };
  2000. case "navigate-tab": {
  2001. const tabState = closeReportView(state);
  2002. return {
  2003. ...tabState,
  2004. activeTab: navItems.some((item) => item.id === tabId) ? tabId : "home",
  2005. systemState: null,
  2006. overlays: {
  2007. ...tabState.overlays,
  2008. appointmentId: null,
  2009. journeyView: null,
  2010. },
  2011. };
  2012. }
  2013. case "open-messages":
  2014. return {
  2015. ...closeReportView(state),
  2016. activeTab: "messages",
  2017. systemState: null,
  2018. };
  2019. case "set-record-filter":
  2020. return {
  2021. ...state,
  2022. activeTab: "records",
  2023. recordsFilter: ["all", "abnormal", "unread"].includes(filter)
  2024. ? filter
  2025. : "all",
  2026. };
  2027. case "open-message": {
  2028. const message = (Array.isArray(state.messages) ? state.messages : [])
  2029. .find((item) => item?.id === messageId);
  2030. if (message?.type === "REPORT") {
  2031. const reportId = typeof message.reportId === "string"
  2032. ? message.reportId
  2033. : null;
  2034. return reportId ? openReportDetails(state, reportId) : state;
  2035. }
  2036. if (message?.type === "JOURNEY") {
  2037. const journey = state.journeyProjections?.[0];
  2038. return journey ? {
  2039. ...state,
  2040. activeTab: journey.stage === "QUEUE_WAITING" ? state.activeTab : "care",
  2041. overlays: {
  2042. ...state.overlays,
  2043. appointmentId: journey.appointmentId,
  2044. journeyView: journey.stage === "QUEUE_WAITING" ? "QUEUE" : null,
  2045. },
  2046. } : state;
  2047. }
  2048. return state;
  2049. }
  2050. case "load-scenario":
  2051. case "reset-scenario": {
  2052. commandConfirmationInFlight = null;
  2053. commandRejectionInFlight = null;
  2054. invalidateReportProjectionTimer();
  2055. return loadScenario(
  2056. state,
  2057. action === "reset-scenario"
  2058. ? state.demoScenario ?? "EMPTY_HOME"
  2059. : scenarioName,
  2060. );
  2061. }
  2062. case "previous-scenario":
  2063. case "next-scenario": {
  2064. const currentIndex = Math.max(
  2065. 0,
  2066. demoScenarios.indexOf(state.demoScenario),
  2067. );
  2068. const delta = action === "previous-scenario" ? -1 : 1;
  2069. const nextIndex = (
  2070. currentIndex + delta + demoScenarios.length
  2071. ) % demoScenarios.length;
  2072. commandConfirmationInFlight = null;
  2073. commandRejectionInFlight = null;
  2074. invalidateReportProjectionTimer();
  2075. return loadScenario(state, demoScenarios[nextIndex]);
  2076. }
  2077. case "toggle-scenario-panel":
  2078. scenarioPanelVisible = !scenarioPanelVisible;
  2079. return state;
  2080. case "set-demo-viewport":
  2081. demoViewport = viewport === "430" ? "430" : "390";
  2082. return state;
  2083. case "submit-registration-demo":
  2084. return submitAssistantText(
  2085. state,
  2086. "我想挂明天神经内科李明主任的号",
  2087. );
  2088. case "submit-assistant-text":
  2089. return submitAssistantText(state, assistantText);
  2090. case "select-candidate": {
  2091. const interaction = buildCandidateSelectionInteraction(
  2092. state,
  2093. candidateId,
  2094. );
  2095. return applyRegistrationCommandBackendFixture(
  2096. applyCandidateSelectionResponseFixture(
  2097. state,
  2098. simulateCandidateSelectionBackendFixture(state, interaction),
  2099. ),
  2100. );
  2101. }
  2102. case "close-command":
  2103. return {
  2104. ...state,
  2105. activeTab: "home",
  2106. overlays: { ...state.overlays, commandId: null },
  2107. };
  2108. case "requery-registration":
  2109. commandConfirmationInFlight = null;
  2110. return submitInteraction({
  2111. ...state,
  2112. overlays: { ...state.overlays, commandId: null },
  2113. }, "我想挂明天神经内科李明主任的号");
  2114. default:
  2115. return state;
  2116. }
  2117. };
  2118. const selectInterpretingReportId = (state) => {
  2119. const reportId = state?.overlays?.reportId;
  2120. const hasInterpretingItem = state?.patientWorkItems?.some(
  2121. (item) => item?.type === "REPORT_REVIEW"
  2122. && item.reportId === reportId
  2123. && item.stage === "INTERPRETING",
  2124. );
  2125. return typeof reportId === "string"
  2126. && state?.overlays?.reportView === "INTERPRETING"
  2127. && evaluateReportInterpretationGate(state, reportId).allowed
  2128. && state?.reportSourceProjections?.[reportId]?.interpretationStatus
  2129. === "GENERATING"
  2130. && hasValidReportConsent(state, reportId)
  2131. && hasInterpretingItem
  2132. ? reportId
  2133. : null;
  2134. };
  2135. const invalidateReportProjectionTimer = () => {
  2136. activeReportProjectionTimer = null;
  2137. reportProjectionGeneration += 1;
  2138. };
  2139. const ensureReportInterpretationProjection = () => {
  2140. if (demoState?.demoScenario === "REPORT_GENERATING") {
  2141. invalidateReportProjectionTimer();
  2142. return;
  2143. }
  2144. const reportId = selectInterpretingReportId(demoState);
  2145. if (!reportId) {
  2146. invalidateReportProjectionTimer();
  2147. return;
  2148. }
  2149. if (
  2150. activeReportProjectionTimer?.reportId === reportId
  2151. && activeReportProjectionTimer.generation === reportProjectionGeneration
  2152. ) {
  2153. return;
  2154. }
  2155. if (typeof globalThis.setTimeout !== "function") {
  2156. return;
  2157. }
  2158. const scheduledGeneration = reportProjectionGeneration;
  2159. const consentId = `consent-ai-${reportId}`;
  2160. const timerToken = {
  2161. generation: scheduledGeneration,
  2162. reportId,
  2163. patientId: demoState?.activePatientId,
  2164. stateContext: demoState,
  2165. consentContext: demoState?.consents?.[consentId],
  2166. bindingContext: demoState?.consentScopeBindings,
  2167. };
  2168. activeReportProjectionTimer = timerToken;
  2169. globalThis.setTimeout(() => {
  2170. if (activeReportProjectionTimer !== timerToken) {
  2171. return;
  2172. }
  2173. activeReportProjectionTimer = null;
  2174. const isCurrentInterpretation = (
  2175. reportProjectionGeneration === scheduledGeneration
  2176. && selectInterpretingReportId(demoState) === reportId
  2177. && demoState?.activePatientId === timerToken.patientId
  2178. && demoState === timerToken.stateContext
  2179. && demoState?.consents?.[consentId]
  2180. === timerToken.consentContext
  2181. && demoState?.consentScopeBindings === timerToken.bindingContext
  2182. );
  2183. if (!isCurrentInterpretation) {
  2184. return;
  2185. }
  2186. demoState = applyReportInterpretationProjectionFixture(
  2187. demoState,
  2188. reportId,
  2189. );
  2190. renderAppShell(demoState);
  2191. }, 120);
  2192. };
  2193. const beginCommandConfirmation = () => {
  2194. const commandId = demoState?.overlays?.commandId;
  2195. if (commandConfirmationInFlight || commandRejectionInFlight || !commandId) {
  2196. return;
  2197. }
  2198. lastCommandConfirmationRequest = buildCommandConfirmationRequest(
  2199. demoState,
  2200. commandId,
  2201. {
  2202. channel: "CARD",
  2203. idempotencyKey: `confirm-${commandId.replace(/^command-/, "")}`,
  2204. },
  2205. );
  2206. const token = { commandId, request: lastCommandConfirmationRequest };
  2207. commandConfirmationInFlight = token;
  2208. if (typeof globalThis.setTimeout !== "function") {
  2209. return;
  2210. }
  2211. globalThis.setTimeout(() => {
  2212. if (
  2213. commandConfirmationInFlight !== token
  2214. || demoState?.commandProjections?.[commandId]?.status
  2215. !== "AWAITING_CONFIRMATION"
  2216. ) {
  2217. return;
  2218. }
  2219. demoState = applyCommandProjectionFixture(demoState, {
  2220. id: `command-executing-${commandId}`,
  2221. commandId,
  2222. version: demoState.commandProjections[commandId].version + 1,
  2223. status: "EXECUTING",
  2224. });
  2225. renderAppShell(demoState);
  2226. globalThis.setTimeout(() => {
  2227. if (
  2228. commandConfirmationInFlight !== token
  2229. || demoState?.commandProjections?.[commandId]?.status !== "EXECUTING"
  2230. ) {
  2231. return;
  2232. }
  2233. demoState = applyCommandProjectionFixture(demoState, {
  2234. id: `command-succeeded-${commandId}`,
  2235. commandId,
  2236. version: demoState.commandProjections[commandId].version + 1,
  2237. status: "SUCCEEDED",
  2238. result: {
  2239. type: "APPOINTMENT_CREATED",
  2240. eventId: `appointment-event-${commandId}`,
  2241. appointmentId: `appointment-${commandId}`,
  2242. patientId: demoState.activePatientId,
  2243. summary: {
  2244. visitDate: demoState.commandProjections[commandId]
  2245. .details?.visitAt?.split(" ")?.[0],
  2246. visitTime: demoState.commandProjections[commandId]
  2247. .details?.visitAt?.split(" ")?.[1],
  2248. departmentName: demoState.commandProjections[commandId]
  2249. .details?.departmentName,
  2250. doctorName: demoState.commandProjections[commandId]
  2251. .details?.doctorName,
  2252. doctorTitle: demoState.commandProjections[commandId]
  2253. .details?.doctorTitle,
  2254. fee: demoState.commandProjections[commandId].details?.fee,
  2255. },
  2256. },
  2257. });
  2258. commandConfirmationInFlight = null;
  2259. renderAppShell(demoState);
  2260. }, 120);
  2261. }, 80);
  2262. };
  2263. const beginCommandRejection = () => {
  2264. const commandId = demoState?.overlays?.commandId;
  2265. if (commandRejectionInFlight || commandConfirmationInFlight || !commandId) {
  2266. return;
  2267. }
  2268. lastCommandRejectionRequest = buildCommandRejectionRequest(
  2269. demoState,
  2270. commandId,
  2271. {
  2272. reason: "暂不操作",
  2273. idempotencyKey: `reject-${commandId.replace(/^command-/, "")}`,
  2274. },
  2275. );
  2276. const token = { commandId, request: lastCommandRejectionRequest };
  2277. commandRejectionInFlight = token;
  2278. if (typeof globalThis.setTimeout !== "function") {
  2279. return;
  2280. }
  2281. globalThis.setTimeout(() => {
  2282. if (
  2283. commandRejectionInFlight !== token
  2284. || demoState?.commandProjections?.[commandId]?.status
  2285. !== "AWAITING_CONFIRMATION"
  2286. ) {
  2287. return;
  2288. }
  2289. demoState = applyCommandProjectionFixture(demoState, {
  2290. id: `command-rejected-${commandId}`,
  2291. commandId,
  2292. version: demoState.commandProjections[commandId].version + 1,
  2293. status: "REJECTED",
  2294. });
  2295. commandRejectionInFlight = null;
  2296. renderAppShell(demoState);
  2297. }, 80);
  2298. };
  2299. export const initializePatientServiceDesk = (state = demoState) => {
  2300. if (state !== demoState) {
  2301. invalidateReportProjectionTimer();
  2302. commandConfirmationInFlight = null;
  2303. commandRejectionInFlight = null;
  2304. lastCommandConfirmationRequest = null;
  2305. lastCommandRejectionRequest = null;
  2306. }
  2307. demoState = state;
  2308. const root = globalThis.document;
  2309. if (root?.addEventListener && !boundDocuments.has(root)) {
  2310. root.addEventListener("click", (event) => {
  2311. const trigger = event.target?.closest?.("[data-action]");
  2312. const overlayRoot = root.getElementById?.("overlayRoot");
  2313. const appView = root.getElementById?.("appView");
  2314. const scenarioPanel = root.getElementById?.("scenarioPanel");
  2315. const appShell = root.querySelector?.(".phone-shell")
  2316. ?? appView?.closest?.(".phone-shell");
  2317. const withinApp = Boolean(
  2318. appShell?.contains?.(trigger)
  2319. || appView?.contains?.(trigger)
  2320. || overlayRoot?.contains?.(trigger)
  2321. || scenarioPanel?.contains?.(trigger),
  2322. );
  2323. if (!trigger || !withinApp) {
  2324. return;
  2325. }
  2326. if (
  2327. (
  2328. hasConsentDialog(demoState)
  2329. || hasCommandDialog(demoState)
  2330. || hasReportServiceDialog(demoState)
  2331. )
  2332. && scenarioPanel?.contains?.(trigger)
  2333. ) {
  2334. return;
  2335. }
  2336. if (trigger.dataset?.action === "interpret-report") {
  2337. consentFocusReturn = {
  2338. reportId: trigger.dataset?.reportId,
  2339. };
  2340. }
  2341. if (trigger.dataset?.action === "confirm-command") {
  2342. beginCommandConfirmation();
  2343. return;
  2344. }
  2345. if (trigger.dataset?.action === "reject-command") {
  2346. beginCommandRejection();
  2347. return;
  2348. }
  2349. demoState = reduceDemoAction(
  2350. demoState,
  2351. trigger.dataset?.action,
  2352. trigger.dataset?.reportId,
  2353. trigger.dataset?.candidateId,
  2354. root.getElementById?.("assistantComposerInput")?.value,
  2355. trigger.dataset?.appointmentId,
  2356. trigger.dataset?.tabId,
  2357. trigger.dataset?.filter,
  2358. trigger.dataset?.messageId,
  2359. trigger.dataset?.scenario,
  2360. trigger.dataset?.viewport,
  2361. );
  2362. renderAppShell(demoState);
  2363. ensureReportInterpretationProjection();
  2364. });
  2365. root.addEventListener("keydown", (event) => {
  2366. if (
  2367. event.key === "Enter"
  2368. && event.target?.id === "assistantComposerInput"
  2369. ) {
  2370. const appView = root.getElementById?.("appView");
  2371. if (!appView?.contains?.(event.target)) {
  2372. return;
  2373. }
  2374. event.preventDefault?.();
  2375. demoState = reduceDemoAction(
  2376. demoState,
  2377. "submit-assistant-text",
  2378. undefined,
  2379. undefined,
  2380. event.target.value,
  2381. );
  2382. renderAppShell(demoState);
  2383. ensureReportInterpretationProjection();
  2384. return;
  2385. }
  2386. const targetTag = event.target?.tagName?.toLowerCase?.();
  2387. const isTyping = (
  2388. ["input", "textarea", "select"].includes(targetTag)
  2389. || event.target?.isContentEditable
  2390. );
  2391. const isPresentationShortcut = (
  2392. !isTyping
  2393. && ["ArrowLeft", "ArrowRight", "r", "R", "p", "P"]
  2394. .includes(event.key)
  2395. );
  2396. const consentDialogOpen = hasConsentDialog(demoState);
  2397. const commandDialogOpen = hasCommandDialog(demoState);
  2398. const reportServiceDialogOpen = hasReportServiceDialog(demoState);
  2399. if (
  2400. isPresentationShortcut
  2401. && (
  2402. consentDialogOpen
  2403. || commandDialogOpen
  2404. || reportServiceDialogOpen
  2405. )
  2406. ) {
  2407. event.preventDefault?.();
  2408. return;
  2409. }
  2410. if (isPresentationShortcut) {
  2411. event.preventDefault?.();
  2412. if (event.key === "p" || event.key === "P") {
  2413. scenarioPanelVisible = !scenarioPanelVisible;
  2414. renderAppShell(demoState);
  2415. return;
  2416. }
  2417. const currentIndex = Math.max(
  2418. 0,
  2419. demoScenarios.indexOf(demoState.demoScenario),
  2420. );
  2421. const nextScenario = event.key === "r" || event.key === "R"
  2422. ? demoState.demoScenario ?? "EMPTY_HOME"
  2423. : demoScenarios[(
  2424. currentIndex
  2425. + (event.key === "ArrowLeft" ? -1 : 1)
  2426. + demoScenarios.length
  2427. ) % demoScenarios.length];
  2428. initializePatientServiceDesk(loadScenario(demoState, nextScenario));
  2429. return;
  2430. }
  2431. if (
  2432. !consentDialogOpen
  2433. && !commandDialogOpen
  2434. && !reportServiceDialogOpen
  2435. ) {
  2436. return;
  2437. }
  2438. if (event.key === "Escape" && reportServiceDialogOpen) {
  2439. event.preventDefault?.();
  2440. demoState = reduceDemoAction(
  2441. demoState,
  2442. "close-report-service",
  2443. );
  2444. renderAppShell(demoState);
  2445. return;
  2446. }
  2447. if (event.key === "Escape" && consentDialogOpen) {
  2448. event.preventDefault?.();
  2449. demoState = rejectConsent(
  2450. demoState,
  2451. demoState.overlays.consentId,
  2452. );
  2453. renderAppShell(demoState);
  2454. ensureReportInterpretationProjection();
  2455. return;
  2456. }
  2457. if (event.key !== "Tab") {
  2458. return;
  2459. }
  2460. const overlayRoot = root.getElementById?.("overlayRoot");
  2461. const focusables = Array.from(
  2462. overlayRoot?.querySelectorAll?.(
  2463. 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  2464. ) ?? [],
  2465. );
  2466. if (focusables.length === 0) {
  2467. event.preventDefault?.();
  2468. overlayRoot?.querySelector?.('[role="dialog"]')?.focus?.();
  2469. return;
  2470. }
  2471. const activeIndex = focusables.indexOf(root.activeElement);
  2472. const nextIndex = event.shiftKey
  2473. ? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)
  2474. : (activeIndex === focusables.length - 1 ? 0 : activeIndex + 1);
  2475. event.preventDefault?.();
  2476. focusables[nextIndex]?.focus?.();
  2477. });
  2478. boundDocuments.add(root);
  2479. }
  2480. const rendered = renderAppShell(demoState);
  2481. ensureReportInterpretationProjection();
  2482. return rendered;
  2483. };
  2484. if (globalThis.document) {
  2485. if (globalThis.document.readyState === "loading") {
  2486. globalThis.document.addEventListener(
  2487. "DOMContentLoaded",
  2488. () => initializePatientServiceDesk(),
  2489. { once: true },
  2490. );
  2491. } else {
  2492. initializePatientServiceDesk();
  2493. }
  2494. }