|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useState } from "react";
|
|
|
+import { useEffect, useState } from "react";
|
|
|
import {
|
|
|
ArrowLeftIcon,
|
|
|
CalendarIcon,
|
|
|
@@ -22,10 +22,13 @@ import {
|
|
|
UploadIcon,
|
|
|
} from "@radix-ui/react-icons";
|
|
|
import {
|
|
|
+ Carousel,
|
|
|
FlowStack,
|
|
|
KeyboardInput,
|
|
|
KeyboardTextarea,
|
|
|
MobileScroll,
|
|
|
+ useKeyboard,
|
|
|
+ useKeyboardInsets,
|
|
|
type FlowControls,
|
|
|
type FlowScreen,
|
|
|
} from "./mobile";
|
|
|
@@ -152,7 +155,11 @@ function HomeContent({ flow }: { flow: FlowControls }) {
|
|
|
</span>
|
|
|
<em>待解读</em>
|
|
|
</button>
|
|
|
- <button className="patient-state-row tongue" type="button" onClick={() => flow.push(tongueCaptureScreen)}>
|
|
|
+ <button
|
|
|
+ className="patient-state-row tongue"
|
|
|
+ type="button"
|
|
|
+ onClick={() => flow.push(makeAssistantScreen("tongue", "继续上次已保存的体质问答"))}
|
|
|
+ >
|
|
|
<span className="state-icon"><HeartIcon /></span>
|
|
|
<span>
|
|
|
<strong>中医舌诊待完成</strong>
|
|
|
@@ -230,57 +237,229 @@ function HomeContent({ flow }: { flow: FlowControls }) {
|
|
|
}
|
|
|
|
|
|
function ServicesContent({ flow }: { flow: FlowControls }) {
|
|
|
- const services = [
|
|
|
+ const catalog = [
|
|
|
{
|
|
|
- title: "智能分诊挂号",
|
|
|
- desc: "症状分析、科室推荐、模拟预约",
|
|
|
- icon: CalendarIcon,
|
|
|
- screen: makeAssistantScreen("registration"),
|
|
|
+ id: "outpatient",
|
|
|
+ label: "门诊服务",
|
|
|
+ stages: [
|
|
|
+ {
|
|
|
+ id: "before",
|
|
|
+ label: "诊前",
|
|
|
+ agents: [
|
|
|
+ { title: "智能导诊", desc: "院内服务与就医流程引导" },
|
|
|
+ { title: "智能分诊", desc: "根据症状推荐合适科室" },
|
|
|
+ { title: "智能分诊挂号", desc: "语音补槽、Mock 号源与预约确认", mode: "registration" as const },
|
|
|
+ { title: "门诊签到叫号", desc: "到院签到与候诊进度提醒" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "during",
|
|
|
+ label: "诊中",
|
|
|
+ agents: [
|
|
|
+ { title: "中医舌诊", desc: "体质问答、舌象采集与综合建议", mode: "tongue" as const },
|
|
|
+ { title: "临床决策支持", desc: "为诊疗过程提供知识辅助" },
|
|
|
+ { title: "检验检查预约", desc: "检查项目预约与注意事项" },
|
|
|
+ { title: "检验检查报告解读", desc: "上传报告、结构化解读与风险提醒", mode: "report" as const },
|
|
|
+ { title: "门诊缴费", desc: "门诊费用查询与缴费引导" },
|
|
|
+ { title: "病历书写", desc: "病历信息整理与结构化生成" },
|
|
|
+ { title: "病历质控", desc: "门诊病历完整性与规范检查" },
|
|
|
+ { title: "自助门诊场景", desc: "常见门诊服务自助办理" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "after",
|
|
|
+ label: "诊后",
|
|
|
+ agents: [
|
|
|
+ { title: "智能随访", desc: "诊后情况采集与随访提醒" },
|
|
|
+ { title: "康复计划生成", desc: "个体化康复任务与计划" },
|
|
|
+ { title: "智能用药提醒", desc: "按医嘱提供用药时间提醒" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
},
|
|
|
{
|
|
|
- title: "报告智能解读",
|
|
|
- desc: "检验检查报告、异常指标、就医建议",
|
|
|
- icon: FileTextIcon,
|
|
|
- screen: makeAssistantScreen("report"),
|
|
|
+ id: "inpatient",
|
|
|
+ label: "住院服务",
|
|
|
+ stages: [
|
|
|
+ {
|
|
|
+ id: "admission",
|
|
|
+ label: "入院前",
|
|
|
+ agents: [
|
|
|
+ { title: "智能预住院评估", desc: "入院前资料与风险信息采集" },
|
|
|
+ { title: "智能预约与入院办理", desc: "预约床位与入院流程引导" },
|
|
|
+ { title: "住院费预交", desc: "预交金查询与缴纳引导" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "ward",
|
|
|
+ label: "住院中",
|
|
|
+ agents: [
|
|
|
+ { title: "入院宣教 / 入科接待", desc: "入科须知和病区服务介绍" },
|
|
|
+ { title: "智慧病历管理", desc: "住院病历信息整理与管理" },
|
|
|
+ { title: "临床决策支持", desc: "住院诊疗知识辅助" },
|
|
|
+ { title: "检验检查报告解读", desc: "住院报告结构化解读" },
|
|
|
+ { title: "AI 病区物联网", desc: "病区设备与状态智能协同" },
|
|
|
+ { title: "智慧护理", desc: "护理任务与患者服务协同" },
|
|
|
+ { title: "智能出院准备与规划", desc: "出院条件与后续事项准备" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "discharge",
|
|
|
+ label: "出院后",
|
|
|
+ agents: [
|
|
|
+ { title: "床旁结算", desc: "出院费用与床旁结算引导" },
|
|
|
+ { title: "远程管理", desc: "出院后健康状态远程管理" },
|
|
|
+ { title: "智能随访", desc: "出院后恢复情况随访" },
|
|
|
+ { title: "康复计划生成", desc: "院外康复任务规划" },
|
|
|
+ { title: "智能用药提醒", desc: "出院用药提醒与反馈" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
},
|
|
|
{
|
|
|
- title: "中医舌诊",
|
|
|
- desc: "体质问答、舌象采集、综合建议",
|
|
|
- icon: HeartIcon,
|
|
|
- screen: makeAssistantScreen("tongue"),
|
|
|
+ id: "checkup",
|
|
|
+ label: "体检服务",
|
|
|
+ stages: [
|
|
|
+ {
|
|
|
+ id: "checkup-before",
|
|
|
+ label: "检前",
|
|
|
+ agents: [
|
|
|
+ { title: "调查问卷", desc: "体检前健康信息采集" },
|
|
|
+ { title: "方案设计", desc: "个体化体检方案建议" },
|
|
|
+ { title: "预约体检", desc: "体检时间与套餐预约" },
|
|
|
+ { title: "检前宣教", desc: "体检准备事项提醒" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "checkup-during",
|
|
|
+ label: "检中",
|
|
|
+ agents: [
|
|
|
+ { title: "智能签到", desc: "到院签到与身份确认" },
|
|
|
+ { title: "智能导检", desc: "检查顺序与位置引导" },
|
|
|
+ { title: "空腹项目优先", desc: "智能安排空腹检查项目" },
|
|
|
+ { title: "科室检查", desc: "检查科室流程协同" },
|
|
|
+ { title: "标本采集", desc: "采集项目与注意事项提示" },
|
|
|
+ { title: "营养早餐", desc: "空腹项目结束后的营养服务" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "checkup-after",
|
|
|
+ label: "检后",
|
|
|
+ agents: [
|
|
|
+ { title: "智能辅助总检审核", desc: "体检结论辅助审核" },
|
|
|
+ { title: "报告生成", desc: "体检结果汇总与报告生成" },
|
|
|
+ { title: "报告解读", desc: "体检报告健康解读" },
|
|
|
+ { title: "绿色通道", desc: "异常结果进一步就医协助" },
|
|
|
+ { title: "档案建立", desc: "院内体检健康记录归档" },
|
|
|
+ { title: "定期随访", desc: "体检后定期健康随访" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "bi",
|
|
|
+ label: "运营管理",
|
|
|
+ stages: [
|
|
|
+ {
|
|
|
+ id: "bi-all",
|
|
|
+ label: "全部",
|
|
|
+ agents: [
|
|
|
+ { title: "全局运营数据问答", desc: "医院运营指标查询与解释" },
|
|
|
+ { title: "运营预测", desc: "运营趋势与资源需求预测" },
|
|
|
+ { title: "财务管控", desc: "财务指标分析与风险提示" },
|
|
|
+ { title: "物资管理", desc: "库存、消耗和补给管理" },
|
|
|
+ { title: "质量管理", desc: "医疗质量指标监测" },
|
|
|
+ { title: "人力绩效", desc: "人员与绩效数据分析" },
|
|
|
+ { title: "设备运维", desc: "设备状态与运维任务管理" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
},
|
|
|
];
|
|
|
+ const [clusterId, setClusterId] = useState("outpatient");
|
|
|
+ const cluster = catalog.find((item) => item.id === clusterId) ?? catalog[0];
|
|
|
+ const [stageId, setStageId] = useState(cluster.stages[0].id);
|
|
|
+ const activeStage = cluster.stages.find((stage) => stage.id === stageId) ?? cluster.stages[0];
|
|
|
+
|
|
|
+ const selectCluster = (id: string) => {
|
|
|
+ const next = catalog.find((item) => item.id === id) ?? catalog[0];
|
|
|
+ setClusterId(id);
|
|
|
+ setStageId(next.stages[0].id);
|
|
|
+ };
|
|
|
+
|
|
|
return (
|
|
|
<MobileScroll className="portal-scroll">
|
|
|
- <main className="portal-page root-list-page">
|
|
|
- <header>
|
|
|
- <h1>服务</h1>
|
|
|
- <p>空海医院已开通的智能服务</p>
|
|
|
+ <main className="portal-page root-list-page service-catalog-page">
|
|
|
+ <header className="catalog-header">
|
|
|
+ <span>医梦患者智能服务门户</span>
|
|
|
+ <h1>智能服务</h1>
|
|
|
+ <p>浏览医梦可为医院提供的智能体能力</p>
|
|
|
</header>
|
|
|
- <section className="grouped-list">
|
|
|
- {services.map((service) => {
|
|
|
- const Icon = service.icon;
|
|
|
+
|
|
|
+ <label className="service-search">
|
|
|
+ <ReaderIcon />
|
|
|
+ <KeyboardInput placeholder="搜索智能体服务" aria-label="搜索智能体服务" />
|
|
|
+ </label>
|
|
|
+
|
|
|
+ <Carousel ariaLabel="智能体业务目录" className="catalog-cluster-carousel">
|
|
|
+ {catalog.map((item) => (
|
|
|
+ <button
|
|
|
+ key={item.id}
|
|
|
+ type="button"
|
|
|
+ className={item.id === cluster.id ? "selected" : ""}
|
|
|
+ onClick={() => selectCluster(item.id)}
|
|
|
+ >
|
|
|
+ {item.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </Carousel>
|
|
|
+
|
|
|
+ <Carousel ariaLabel={`${cluster.label}阶段`} className="catalog-stage-carousel">
|
|
|
+ {cluster.stages.map((stage) => (
|
|
|
+ <button
|
|
|
+ key={stage.id}
|
|
|
+ type="button"
|
|
|
+ className={stage.id === activeStage.id ? "selected" : ""}
|
|
|
+ onClick={() => setStageId(stage.id)}
|
|
|
+ >
|
|
|
+ {stage.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </Carousel>
|
|
|
+
|
|
|
+ <div className="catalog-summary">
|
|
|
+ <strong>{cluster.label} · {activeStage.label}</strong>
|
|
|
+ <span>{activeStage.agents.length} 项能力</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <section className="agent-catalog-list">
|
|
|
+ {activeStage.agents.map((agent) => {
|
|
|
+ const Icon = agent.mode === "registration" ? CalendarIcon : agent.mode === "report" ? FileTextIcon : agent.mode === "tongue" ? HeartIcon : DashboardIcon;
|
|
|
+ const enabled = Boolean(agent.mode);
|
|
|
return (
|
|
|
- <button key={service.title} type="button" onClick={() => flow.push(service.screen)}>
|
|
|
- <span className="list-icon">
|
|
|
+ <button
|
|
|
+ key={agent.title}
|
|
|
+ type="button"
|
|
|
+ disabled={!enabled}
|
|
|
+ className={enabled ? "enabled" : "disabled"}
|
|
|
+ onClick={() => agent.mode && flow.push(makeAssistantScreen(agent.mode))}
|
|
|
+ >
|
|
|
+ <span className={`catalog-agent-icon ${agent.mode ?? "locked"}`}>
|
|
|
<Icon />
|
|
|
</span>
|
|
|
- <span>
|
|
|
- <strong>{service.title}</strong>
|
|
|
- <small>{service.desc}</small>
|
|
|
+ <span className="catalog-agent-copy">
|
|
|
+ <span className="catalog-agent-title">
|
|
|
+ <strong>{agent.title}</strong>
|
|
|
+ <em>{enabled ? "P0 已开通" : "本院未开通"}</em>
|
|
|
+ </span>
|
|
|
+ <small>{agent.desc}</small>
|
|
|
</span>
|
|
|
- <ChevronRightIcon />
|
|
|
+ {enabled ? <ChevronRightIcon /> : null}
|
|
|
</button>
|
|
|
);
|
|
|
})}
|
|
|
</section>
|
|
|
- <section className="plain-info">
|
|
|
- <InfoCircledIcon />
|
|
|
- <div>
|
|
|
- <strong>服务范围</strong>
|
|
|
- <p>当前为单医院 P0 演示。所有挂号、报告和支付数据均为 Mock。</p>
|
|
|
- </div>
|
|
|
- </section>
|
|
|
+ <p className="catalog-footnote">P0 仅开放智能分诊挂号、检验检查报告解读和中医舌诊,其余能力仅用于展示平台规划。</p>
|
|
|
</main>
|
|
|
</MobileScroll>
|
|
|
);
|
|
|
@@ -306,7 +485,7 @@ function RecordsContent({ flow }: { flow: FlowControls }) {
|
|
|
<span><strong>血常规智能解读</strong><small>今天 14:32 · 已完成</small><p>发现 3 项异常指标</p></span>
|
|
|
<em>查看</em>
|
|
|
</button>
|
|
|
- <button type="button" onClick={() => flow.push(tongueCaptureScreen)}>
|
|
|
+ <button type="button" onClick={() => flow.push(makeAssistantScreen("tongue", "继续上次已保存的体质问答"))}>
|
|
|
<span className="record-icon tongue"><HeartIcon /></span>
|
|
|
<span><strong>中医舌诊评估</strong><small>今天 13:10 · 待上传</small><p>问答已完成,待拍摄舌象</p></span>
|
|
|
<em>继续</em>
|
|
|
@@ -316,12 +495,15 @@ function RecordsContent({ flow }: { flow: FlowControls }) {
|
|
|
<span><strong>神经内科预约</strong><small>明天 09:30 · 已完成</small><p>李明 主任医师</p></span>
|
|
|
<em>查看</em>
|
|
|
</button>
|
|
|
- <button type="button" onClick={() => flow.push(registrationFailureScreen)}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => flow.push(makeAssistantScreen("registration", "继续查询神经内科明天上午的专家号", "complete"))}
|
|
|
+ >
|
|
|
<span className="record-icon warning"><ReloadIcon /></span>
|
|
|
<span><strong>智能挂号任务</strong><small>今天 12:20 · 未完成</small><p>号源发生变化,挂号条件已保留</p></span>
|
|
|
<em>重试</em>
|
|
|
</button>
|
|
|
- <button type="button" onClick={() => flow.push(reportFileFailureScreen)}>
|
|
|
+ <button type="button" onClick={() => flow.push(makeAssistantScreen("report"))}>
|
|
|
<span className="record-icon warning"><FileTextIcon /></span>
|
|
|
<span><strong>报告解读任务</strong><small>今天 11:08 · 待重新上传</small><p>原图片模糊,患者信息已保留</p></span>
|
|
|
<em>继续</em>
|
|
|
@@ -766,24 +948,512 @@ function AssistantExperience({
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+type AgentConversationMode = Exclude<AssistantMode, "general">;
|
|
|
+type RegistrationPhase = "start" | "doctor" | "slots" | "confirm" | "done" | "emergency";
|
|
|
+type ReportPhase = "start" | "processing" | "result";
|
|
|
+type TonguePhase = "question-one" | "question-two" | "upload" | "processing" | "result";
|
|
|
+
|
|
|
+function AgentConversationExperience({
|
|
|
+ flow,
|
|
|
+ mode,
|
|
|
+ initialText,
|
|
|
+ initialStage,
|
|
|
+}: {
|
|
|
+ flow: FlowControls;
|
|
|
+ mode: AgentConversationMode;
|
|
|
+ initialText: string;
|
|
|
+ initialStage: AssistantStage;
|
|
|
+}) {
|
|
|
+ const keyboard = useKeyboard();
|
|
|
+ const { bottomInset } = useKeyboardInsets();
|
|
|
+ const [draft, setDraft] = useState("");
|
|
|
+ const [registrationPhase, setRegistrationPhase] = useState<RegistrationPhase>(
|
|
|
+ initialStage === "complete" ? "slots" : initialText ? "doctor" : "start",
|
|
|
+ );
|
|
|
+ const [reportPhase, setReportPhase] = useState<ReportPhase>(
|
|
|
+ initialStage === "complete" || initialText ? "start" : "start",
|
|
|
+ );
|
|
|
+ const [tonguePhase, setTonguePhase] = useState<TonguePhase>(
|
|
|
+ initialText ? "upload" : "question-one",
|
|
|
+ );
|
|
|
+ const [followupQuestion, setFollowupQuestion] = useState("");
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (registrationPhase === "done" || registrationPhase === "emergency" || reportPhase === "result" || tonguePhase === "result") {
|
|
|
+ keyboard.hide();
|
|
|
+ }
|
|
|
+ }, [registrationPhase, reportPhase, tonguePhase]);
|
|
|
+
|
|
|
+ const sendRegistration = (value?: string) => {
|
|
|
+ const content = (value ?? draft).trim();
|
|
|
+ keyboard.hide();
|
|
|
+ setDraft("");
|
|
|
+ if (content.includes("胸痛") || content.includes("呼吸困难")) {
|
|
|
+ setRegistrationPhase("emergency");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (registrationPhase === "start") setRegistrationPhase("doctor");
|
|
|
+ else if (registrationPhase === "doctor") setRegistrationPhase("slots");
|
|
|
+ };
|
|
|
+
|
|
|
+ const sendTongue = (value?: string) => {
|
|
|
+ keyboard.hide();
|
|
|
+ setDraft("");
|
|
|
+ if (tonguePhase === "question-one") setTonguePhase("question-two");
|
|
|
+ else if (tonguePhase === "question-two") setTonguePhase("upload");
|
|
|
+ else if (value === "upload") setTonguePhase("processing");
|
|
|
+ };
|
|
|
+
|
|
|
+ const uploadReport = () => {
|
|
|
+ keyboard.hide();
|
|
|
+ setReportPhase("processing");
|
|
|
+ };
|
|
|
+
|
|
|
+ const composerPlaceholder =
|
|
|
+ mode === "registration"
|
|
|
+ ? registrationPhase === "doctor"
|
|
|
+ ? "说出医生姓名,或说“不限医生”"
|
|
|
+ : "继续描述挂号需求"
|
|
|
+ : mode === "report"
|
|
|
+ ? "可继续追问这份报告"
|
|
|
+ : tonguePhase === "question-one"
|
|
|
+ ? "说出近期最明显的不适"
|
|
|
+ : "继续回答问题";
|
|
|
+
|
|
|
+ const sendDraft = () => {
|
|
|
+ if (mode === "registration") sendRegistration();
|
|
|
+ else if (mode === "tongue") sendTongue();
|
|
|
+ else if (mode === "report" && draft.trim()) {
|
|
|
+ setFollowupQuestion(draft.trim());
|
|
|
+ setDraft("");
|
|
|
+ keyboard.hide();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const defaultAnswer = (() => {
|
|
|
+ if (mode === "registration") {
|
|
|
+ if (registrationPhase === "start") return "我头疼,帮我挂神经内科明天上午九点的专家号";
|
|
|
+ if (registrationPhase === "doctor") return "我想挂李明医生";
|
|
|
+ if (registrationPhase === "slots") return "我选择明天上午 09:00 的号源";
|
|
|
+ if (registrationPhase === "confirm") return "确认以上挂号信息";
|
|
|
+ if (registrationPhase === "done") return "请告诉我就诊前需要注意什么";
|
|
|
+ return "请告诉我最近的急诊入口";
|
|
|
+ }
|
|
|
+ if (mode === "report") {
|
|
|
+ if (reportPhase === "start") return "我想上传一份血常规报告";
|
|
|
+ if (reportPhase === "processing") return "请继续分析这份报告";
|
|
|
+ return "请解释血小板偏低是什么意思";
|
|
|
+ }
|
|
|
+ if (tonguePhase === "question-one") return "我最近容易疲倦,饭后有些腹胀";
|
|
|
+ if (tonguePhase === "question-two") return "我平时更容易怕冷";
|
|
|
+ if (tonguePhase === "upload") return "我已准备好拍摄舌象";
|
|
|
+ if (tonguePhase === "processing") return "请继续生成综合结果";
|
|
|
+ return "我在饮食上需要注意什么";
|
|
|
+ })();
|
|
|
+
|
|
|
+ const useDefaultAnswer = () => {
|
|
|
+ setFollowupQuestion("");
|
|
|
+ if (mode === "registration") {
|
|
|
+ if (registrationPhase === "start") sendRegistration(defaultAnswer);
|
|
|
+ else if (registrationPhase === "doctor") sendRegistration("李明医生");
|
|
|
+ else if (registrationPhase === "slots") setRegistrationPhase("confirm");
|
|
|
+ else if (registrationPhase === "confirm") setRegistrationPhase("done");
|
|
|
+ else setFollowupQuestion(defaultAnswer);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (mode === "report") {
|
|
|
+ if (reportPhase === "start") uploadReport();
|
|
|
+ else if (reportPhase === "processing") setReportPhase("result");
|
|
|
+ else setFollowupQuestion(defaultAnswer);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (tonguePhase === "question-one" || tonguePhase === "question-two") sendTongue(defaultAnswer);
|
|
|
+ else if (tonguePhase === "upload") sendTongue("upload");
|
|
|
+ else if (tonguePhase === "processing") setTonguePhase("result");
|
|
|
+ else setFollowupQuestion(defaultAnswer);
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="agent-conversation-shell">
|
|
|
+ <MobileScroll className="agent-conversation-scroll">
|
|
|
+ <main className={`agent-conversation ${mode}`}>
|
|
|
+ <section className="agent-session-intro">
|
|
|
+ <span className={`service-icon ${mode}`}>
|
|
|
+ {mode === "registration" ? <CalendarIcon /> : mode === "report" ? <FileTextIcon /> : <HeartIcon />}
|
|
|
+ </span>
|
|
|
+ <div>
|
|
|
+ <strong>
|
|
|
+ {mode === "registration" ? "智能分诊挂号" : mode === "report" ? "报告智能解读" : "中医舌诊"}
|
|
|
+ </strong>
|
|
|
+ <small>本次服务独立保存 · 演示数据为 Mock</small>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {mode === "registration" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>您好,我会通过连续追问收集挂号所需信息。您可以直接用一句话说出症状、科室、日期、时间和号别。</p>
|
|
|
+ </AssistantMessage>
|
|
|
+
|
|
|
+ {registrationPhase === "start" ? (
|
|
|
+ <section className="conversation-demo-prompt">
|
|
|
+ <strong>推荐演示语句</strong>
|
|
|
+ <p>“我头疼,帮我挂神经内科明天早上九点的专家号。”</p>
|
|
|
+ <button type="button" onClick={() => sendRegistration("我头疼,帮我挂神经内科明天早上九点的专家号")}>
|
|
|
+ <SpeakerLoudIcon /> 点击模拟语音输入
|
|
|
+ </button>
|
|
|
+ <button className="danger-plain-action" type="button" onClick={() => sendRegistration("我胸痛,还呼吸困难")}>
|
|
|
+ 演示急诊风险识别
|
|
|
+ </button>
|
|
|
+ </section>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase !== "start" && registrationPhase !== "emergency" ? (
|
|
|
+ <UserMessage label="语音已转文字">
|
|
|
+ {initialText || "我头疼,帮我挂神经内科明天早上九点的专家号"}
|
|
|
+ </UserMessage>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase === "doctor" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>我已记录:头疼、神经内科、明天上午 09:00、专家号。当前未发现明确急诊红旗。</p>
|
|
|
+ <p><strong>还差最后一项:您想挂哪位医生?</strong></p>
|
|
|
+ </AssistantMessage>
|
|
|
+ <section className="doctor-return-note">
|
|
|
+ <span>Mock MCP 返回</span>
|
|
|
+ <strong>李明主任、王佳副主任、周海主任</strong>
|
|
|
+ </section>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {["slots", "confirm", "done"].includes(registrationPhase) ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>李明主任</UserMessage>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>信息已经收集完整,正在查询符合条件的 Mock 号源。</p>
|
|
|
+ </AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase === "slots" ? (
|
|
|
+ <section className="conversation-markdown">
|
|
|
+ <div className="markdown-heading">
|
|
|
+ <strong>可预约号源</strong>
|
|
|
+ <span>Mock HIS</span>
|
|
|
+ </div>
|
|
|
+ <p>神经内科 · 李明 主任医师 · 明天上午</p>
|
|
|
+ <button type="button" onClick={() => setRegistrationPhase("confirm")}>
|
|
|
+ <span><strong>09:00</strong><small>专家号 · ¥25 · 剩余 2</small></span>
|
|
|
+ <em>选择</em>
|
|
|
+ </button>
|
|
|
+ <button type="button">
|
|
|
+ <span><strong>09:30</strong><small>专家号 · ¥25 · 剩余 1</small></span>
|
|
|
+ <em>选择</em>
|
|
|
+ </button>
|
|
|
+ </section>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase === "confirm" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>选择明天 09:00</UserMessage>
|
|
|
+ <AssistantMessage>
|
|
|
+ <div className="markdown-result compact-result">
|
|
|
+ <h3>请确认挂号信息</h3>
|
|
|
+ <ul>
|
|
|
+ <li>就诊人:王*明</li>
|
|
|
+ <li>科室:神经内科</li>
|
|
|
+ <li>医生:李明 主任医师</li>
|
|
|
+ <li>时间:明天 09:00</li>
|
|
|
+ <li>费用:¥25(Mock)</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </AssistantMessage>
|
|
|
+ <button className="conversation-confirm" type="button" onClick={() => setRegistrationPhase("done")}>
|
|
|
+ 确认 Mock 挂号
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase === "done" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>确认挂号</UserMessage>
|
|
|
+ <AssistantMessage tone="success">
|
|
|
+ <div className="markdown-result compact-result">
|
|
|
+ <h3>挂号成功(Mock)</h3>
|
|
|
+ <p>明天 09:00 · 神经内科 · 李明 主任医师</p>
|
|
|
+ <p>请提前 15 分钟到院,并携带有效就诊凭证。</p>
|
|
|
+ </div>
|
|
|
+ </AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {registrationPhase === "emergency" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage label="语音已转文字">我胸痛,还呼吸困难,想挂明天心内科。</UserMessage>
|
|
|
+ <AssistantMessage tone="danger">
|
|
|
+ <div className="markdown-result compact-result">
|
|
|
+ <h3>请立即寻求紧急医疗帮助</h3>
|
|
|
+ <p>“胸痛 + 呼吸困难”可能存在紧急风险,已停止普通挂号流程。</p>
|
|
|
+ <p>请立即拨打 120 或前往最近的急诊。如身边有人,请让其陪同。</p>
|
|
|
+ </div>
|
|
|
+ </AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+ {followupQuestion && registrationPhase === "done" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>{followupQuestion}</UserMessage>
|
|
|
+ <AssistantMessage><p>建议提前 15 分钟到院,携带有效就诊凭证和既往检查资料;如症状明显加重,请及时转急诊。</p></AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {mode === "report" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>请在本次对话中上传清晰的检验或检查报告图片。识别后,我会按“风险提醒、异常指标、联合解读和行动建议”整理结果。</p>
|
|
|
+ </AssistantMessage>
|
|
|
+
|
|
|
+ {reportPhase === "start" ? (
|
|
|
+ <button className="conversation-upload" type="button" onClick={uploadReport}>
|
|
|
+ <UploadIcon />
|
|
|
+ <span><strong>上传演示血常规</strong><small>支持图片或 PDF · 点击体验</small></span>
|
|
|
+ </button>
|
|
|
+ ) : (
|
|
|
+ <UserMessage label="已上传文件">
|
|
|
+ <span className="message-file"><FileTextIcon /> 血常规_2026-07-27.jpg</span>
|
|
|
+ </UserMessage>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {reportPhase === "processing" ? (
|
|
|
+ <AssistantMessage>
|
|
|
+ <div className="conversation-processing">
|
|
|
+ <span className="processing-spinner" />
|
|
|
+ <div><strong>正在识别和解读报告</strong><small>正在校验患者信息、指标名称、参考范围和风险项</small></div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ className="inline-demo-action"
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ keyboard.hide();
|
|
|
+ setReportPhase("result");
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 完成演示分析
|
|
|
+ </button>
|
|
|
+ </AssistantMessage>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {reportPhase === "result" ? (
|
|
|
+ <AssistantMessage tone="result">
|
|
|
+ <div className="markdown-result">
|
|
|
+ <h2>血常规解读</h2>
|
|
|
+ <blockquote><strong>风险提醒:</strong>本演示未触发危急值,但血小板偏低。如伴随明显出血、黑便或持续头晕,请及时就医。</blockquote>
|
|
|
+ <h3>报告概览</h3>
|
|
|
+ <p>共识别 22 项指标,其中 <strong>3 项异常</strong>,主要集中在血小板相关指标。</p>
|
|
|
+ <h3>异常指标</h3>
|
|
|
+ <div className="markdown-table">
|
|
|
+ <div><strong>指标</strong><strong>结果</strong><strong>状态</strong></div>
|
|
|
+ <div><span>血小板 PLT</span><span>78 ×10⁹/L</span><em>偏低</em></div>
|
|
|
+ <div><span>平均血小板体积</span><span>12.8 fL</span><em>偏高</em></div>
|
|
|
+ <div><span>血小板分布宽度</span><span>17.2%</span><em>偏高</em></div>
|
|
|
+ </div>
|
|
|
+ <h3>联合解读</h3>
|
|
|
+ <p>血小板计数降低,同时平均血小板体积升高。需要结合近期感染、用药、出血表现和既往趋势进一步判断。</p>
|
|
|
+ <h3>建议下一步</h3>
|
|
|
+ <ol>
|
|
|
+ <li>携带本次及既往报告咨询原就诊科室或血液科。</li>
|
|
|
+ <li>如近期服用影响凝血的药物,请主动告知医生。</li>
|
|
|
+ <li>正式判断以医院医生意见为准。</li>
|
|
|
+ </ol>
|
|
|
+ </div>
|
|
|
+ </AssistantMessage>
|
|
|
+ ) : null}
|
|
|
+ {followupQuestion && reportPhase === "result" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>{followupQuestion}</UserMessage>
|
|
|
+ <AssistantMessage><p>血小板偏低表示参与止血的血小板数量少于本报告参考范围。单次结果不能直接判断病因,需要结合出血表现、近期感染、用药和复查趋势由医生综合评估。</p></AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {mode === "tongue" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>我会先了解您近期的身体感受,再引导拍摄舌象。分析结果用于健康参考,不能替代中医师完整辨证。</p>
|
|
|
+ </AssistantMessage>
|
|
|
+
|
|
|
+ {tonguePhase === "question-one" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage><p><strong>第 1 题:</strong>最近最明显的不适是什么?</p></AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {tonguePhase !== "question-one" ? <UserMessage>容易疲倦,饭后有些腹胀</UserMessage> : null}
|
|
|
+
|
|
|
+ {tonguePhase === "question-two" ? (
|
|
|
+ <>
|
|
|
+ <AssistantMessage><p><strong>第 2 题:</strong>平时更容易怕冷还是怕热?</p></AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {["upload", "processing", "result"].includes(tonguePhase) ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>更怕冷</UserMessage>
|
|
|
+ <AssistantMessage>
|
|
|
+ <p>问答已完成。请在自然光下伸出舌体,避免滤镜,确保舌尖和舌根完整入镜。</p>
|
|
|
+ </AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {tonguePhase === "upload" ? (
|
|
|
+ <button className="conversation-upload tongue-upload" type="button" onClick={() => sendTongue("upload")}>
|
|
|
+ <CameraIcon />
|
|
|
+ <span><strong>拍摄并上传演示舌象</strong><small>正面 · 清晰 · 自然光</small></span>
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {["processing", "result"].includes(tonguePhase) ? (
|
|
|
+ <UserMessage label="已上传图片">
|
|
|
+ <span className="message-file"><CameraIcon /> 舌象照片.jpg</span>
|
|
|
+ </UserMessage>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {tonguePhase === "processing" ? (
|
|
|
+ <AssistantMessage>
|
|
|
+ <div className="conversation-processing">
|
|
|
+ <span className="processing-spinner" />
|
|
|
+ <div><strong>正在分析舌象</strong><small>正在调用医院已接入的舌象分析服务并融合问答信息</small></div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ className="inline-demo-action"
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ keyboard.hide();
|
|
|
+ setTonguePhase("result");
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 返回演示结果
|
|
|
+ </button>
|
|
|
+ </AssistantMessage>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {tonguePhase === "result" ? (
|
|
|
+ <AssistantMessage tone="result">
|
|
|
+ <div className="markdown-result">
|
|
|
+ <h2>中医舌诊综合结果</h2>
|
|
|
+ <blockquote className="positive"><strong>综合倾向:</strong>气虚体质,兼有脾气虚表现(仅供健康参考)</blockquote>
|
|
|
+ <h3>问答信息</h3>
|
|
|
+ <ul><li>容易疲倦、饭后腹胀</li><li>平时较怕冷</li></ul>
|
|
|
+ <h3>舌象特征</h3>
|
|
|
+ <ul><li>舌色:淡红</li><li>舌体:偏胖,可见齿痕</li><li>舌苔:薄白,偏润</li></ul>
|
|
|
+ <h3>综合建议</h3>
|
|
|
+ <ol><li>饮食规律,减少生冷油腻。</li><li>保持适量运动和充足睡眠。</li><li>如疲倦或腹胀持续加重,建议由中医师结合脉诊进一步辨证。</li></ol>
|
|
|
+ </div>
|
|
|
+ </AssistantMessage>
|
|
|
+ ) : null}
|
|
|
+ {followupQuestion && tonguePhase === "result" ? (
|
|
|
+ <>
|
|
|
+ <UserMessage>{followupQuestion}</UserMessage>
|
|
|
+ <AssistantMessage><p>饮食以温热、清淡、规律为主,减少生冷和油腻食物。建议把持续疲倦、腹胀等情况记录下来,必要时由中医师结合脉诊进一步辨证。</p></AssistantMessage>
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+ </>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ <div className="conversation-end-space" />
|
|
|
+ </main>
|
|
|
+ </MobileScroll>
|
|
|
+
|
|
|
+ <div className="conversation-input-dock" style={{ bottom: bottomInset }}>
|
|
|
+ <button className="default-answer" type="button" onClick={useDefaultAnswer}>
|
|
|
+ <span>推荐回答</span>
|
|
|
+ <strong>{defaultAnswer}</strong>
|
|
|
+ <ChevronRightIcon />
|
|
|
+ </button>
|
|
|
+ <div className="conversation-composer">
|
|
|
+ <button className="composer-voice" type="button" onClick={useDefaultAnswer} aria-label="按住说话">
|
|
|
+ <SpeakerLoudIcon />
|
|
|
+ </button>
|
|
|
+ <KeyboardInput
|
|
|
+ value={draft}
|
|
|
+ onChange={(event) => setDraft(event.target.value)}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (event.key === "Enter" && draft.trim()) sendDraft();
|
|
|
+ }}
|
|
|
+ placeholder={composerPlaceholder}
|
|
|
+ aria-label="输入消息"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ className="composer-camera"
|
|
|
+ type="button"
|
|
|
+ aria-label={draft ? "发送文字" : "拍照或上传图片"}
|
|
|
+ onClick={() => {
|
|
|
+ if (draft) {
|
|
|
+ sendDraft();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (mode === "report") uploadReport();
|
|
|
+ if (mode === "tongue" && tonguePhase === "upload") sendTongue("upload");
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {draft ? <ChevronRightIcon /> : <CameraIcon />}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <small>语音内容会先转为文字,重要信息需确认后执行</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function AssistantMessage({
|
|
|
+ children,
|
|
|
+ tone = "default",
|
|
|
+}: {
|
|
|
+ children: React.ReactNode;
|
|
|
+ tone?: "default" | "success" | "danger" | "result";
|
|
|
+}) {
|
|
|
+ return <article className={`conversation-message assistant ${tone}`}>{children}</article>;
|
|
|
+}
|
|
|
+
|
|
|
+function UserMessage({ children, label }: { children: React.ReactNode; label?: string }) {
|
|
|
+ return (
|
|
|
+ <article className="conversation-message user">
|
|
|
+ {label ? <small>{label}</small> : null}
|
|
|
+ <p>{children}</p>
|
|
|
+ </article>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function makeAssistantScreen(
|
|
|
mode: AssistantMode,
|
|
|
initialText = "",
|
|
|
initialStage: AssistantStage = "ready",
|
|
|
): FlowScreen {
|
|
|
- const title = mode === "registration" ? "语音挂号" : mode === "report" ? "报告解读" : mode === "tongue" ? "中医舌诊" : "智能助手";
|
|
|
+ const title = mode === "registration" ? "智能分诊挂号" : mode === "report" ? "报告智能解读" : mode === "tongue" ? "中医舌诊" : "智能助手";
|
|
|
return {
|
|
|
id: `assistant-${mode}-${initialStage}`,
|
|
|
title,
|
|
|
headerHeight: 52,
|
|
|
header: (flow) => <PageHeader flow={flow} title={title} />,
|
|
|
render: (flow) => (
|
|
|
- <AssistantExperience
|
|
|
- flow={flow}
|
|
|
- mode={mode}
|
|
|
- initialText={initialText}
|
|
|
- initialStage={initialStage}
|
|
|
- />
|
|
|
+ mode === "general" ? (
|
|
|
+ <AssistantExperience
|
|
|
+ flow={flow}
|
|
|
+ mode={mode}
|
|
|
+ initialText={initialText}
|
|
|
+ initialStage={initialStage}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <AgentConversationExperience
|
|
|
+ flow={flow}
|
|
|
+ mode={mode}
|
|
|
+ initialText={initialText}
|
|
|
+ initialStage={initialStage}
|
|
|
+ />
|
|
|
+ )
|
|
|
),
|
|
|
};
|
|
|
}
|