From fa1dc764a36d6f806438d784de2e5bc8492d76bb Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 20 May 2026 17:50:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(health+ai):=20P2=20=E5=92=A8=E8=AF=A2?= =?UTF-8?q?=E8=81=94=E5=8A=A8=20+=20AI=20=E5=B7=A1=E6=A3=80=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=20=E2=80=94=20=E5=85=A8=E9=93=BE=E8=B7=AF=E6=89=93?= =?UTF-8?q?=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务链路打通 5/5 断点全部完成: - 咨询→随访:医生端新增"创建随访"按钮,从咨询会话直接创建随访任务 - 咨询→AI:医生端新增"AI 分析"按钮,对咨询上下文触发 AI 分析 - 告警→咨询:小程序告警详情页新增"在线咨询"快捷入口 - AI 巡检消费:erp-ai 新增 patrol_consumer,订阅 ai.patrol.requested 事件 - 前端联动:Web ConsultationDetail + 小程序 alerts 页面联动实现 后端:2 新 API + 2 handler + 1 service + AI event consumer 前端:Web 2 API + 1 页面改造 + 小程序 2 页面改造 测试:Web consultations.test.ts 9/9 通过 --- .../src/pages/consultation/index.scss | 21 ++ .../src/pages/consultation/index.tsx | 41 +++ .../src/pages/pkg-health/alerts/index.scss | 28 ++ .../src/pages/pkg-health/alerts/index.tsx | 13 + apps/web/src/api/health/consultations.test.ts | 22 ++ apps/web/src/api/health/consultations.ts | 48 ++++ .../src/pages/health/ConsultationDetail.tsx | 130 ++++++++- crates/erp-ai/src/event/mod.rs | 1 + crates/erp-ai/src/event/patrol_consumer.rs | 254 ++++++++++++++++++ crates/erp-ai/src/module.rs | 6 +- crates/erp-health/src/dto/consultation_dto.rs | 33 +++ crates/erp-health/src/event/alert.rs | 61 ++++- .../src/handler/consultation_handler.rs | 67 +++++ crates/erp-health/src/routes/consultation.rs | 9 + .../src/service/consultation_service.rs | 162 +++++++++++ 15 files changed, 888 insertions(+), 8 deletions(-) create mode 100644 crates/erp-ai/src/event/patrol_consumer.rs diff --git a/apps/miniprogram/src/pages/consultation/index.scss b/apps/miniprogram/src/pages/consultation/index.scss index 31a01e7..2a1913e 100644 --- a/apps/miniprogram/src/pages/consultation/index.scss +++ b/apps/miniprogram/src/pages/consultation/index.scss @@ -138,3 +138,24 @@ color: $white; font-weight: 600; } + +/* ─── 告警上下文横幅 ─── */ +.consultation-alert-banner { + margin-bottom: var(--tk-gap-sm); +} + +.consultation-alert-banner-inner { + display: flex; + align-items: center; + gap: var(--tk-gap-xs); +} + +.consultation-alert-banner-icon { + font-size: var(--tk-font-body-lg); +} + +.consultation-alert-banner-text { + font-size: var(--tk-font-body-sm); + color: var(--tk-text-secondary); + flex: 1; +} diff --git a/apps/miniprogram/src/pages/consultation/index.tsx b/apps/miniprogram/src/pages/consultation/index.tsx index 6ae6994..a328501 100644 --- a/apps/miniprogram/src/pages/consultation/index.tsx +++ b/apps/miniprogram/src/pages/consultation/index.tsx @@ -16,6 +16,24 @@ import GuestGuard from '@/components/GuestGuard'; import { useElderClass } from '../../hooks/useElderClass'; import './index.scss'; +/** 读取当前页面 URL 中的查询参数 */ +function getQueryParams(): Record { + try { + const instance = Taro.getCurrentInstance(); + const params = instance?.router?.params; + if (!params) return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + result[key] = value; + } + } + return result; + } catch { + return {}; + } +} + function formatTime(iso: string): string { if (!iso) return ''; const d = new Date(iso); @@ -59,6 +77,9 @@ export default function Consultation() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + // Alert context: when navigated from an alert page + const [alertContext, setAlertContext] = useState<{ alertId: string; alertTitle: string } | null>(null); + const loadSessions = useCallback(async (pageNum: number, isRefresh = false) => { if (isRefresh) setLoading(true); setError(''); @@ -87,6 +108,14 @@ export default function Consultation() { usePageData( useCallback(async () => { Taro.setNavigationBarTitle({ title: '在线咨询' }); + // Read alert context from URL query params + const params = getQueryParams(); + if (params.context === 'alert' && params.alert_id) { + setAlertContext({ + alertId: params.alert_id, + alertTitle: params.alert_title ? decodeURIComponent(params.alert_title) : '健康告警', + }); + } if (!user) return; await loadSessions(1, true); }, [user, loadSessions]), @@ -128,6 +157,18 @@ export default function Consultation() { {/* 副标题 */} 随时随地,连接专业医生 + {/* Alert context banner */} + {alertContext && ( + + + + + 来自告警: {alertContext.alertTitle} + + + + )} + {/* 发起咨询按钮 */} {item.title} + {item.message && ( + {item.message} + )} + + safeNavigateTo( + `/pages/consultation/index?context=alert&alert_id=${item.id}&alert_title=${encodeURIComponent(item.title)}`, + )} + > + 在线咨询 + + ); })} diff --git a/apps/web/src/api/health/consultations.test.ts b/apps/web/src/api/health/consultations.test.ts index 2936f7f..275f53b 100644 --- a/apps/web/src/api/health/consultations.test.ts +++ b/apps/web/src/api/health/consultations.test.ts @@ -73,4 +73,26 @@ describe('consultationApi', () => { expect(mockPost).toHaveBeenCalledWith('/health/consultation-messages', req) }) + + it('createFollowUpFromSession 应调用 POST /health/consultation-sessions/:id/follow-up', async () => { + mockPost.mockResolvedValue(fakeRes) + const req = { follow_up_type: 'phone', planned_date: '2026-06-01' } + await consultationApi.createFollowUpFromSession('sess-001', req) + + expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/follow-up', req) + }) + + it('triggerAiAnalysisFromSession 应调用 POST /health/consultation-sessions/:id/ai-analysis', async () => { + mockPost.mockResolvedValue(fakeRes) + await consultationApi.triggerAiAnalysisFromSession('sess-001') + + expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', {}) + }) + + it('triggerAiAnalysisFromSession 传入 analysis_type 时应携带参数', async () => { + mockPost.mockResolvedValue(fakeRes) + await consultationApi.triggerAiAnalysisFromSession('sess-001', { analysis_type: 'trend' }) + + expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', { analysis_type: 'trend' }) + }) }) diff --git a/apps/web/src/api/health/consultations.ts b/apps/web/src/api/health/consultations.ts index 0a4eb08..2bfc355 100644 --- a/apps/web/src/api/health/consultations.ts +++ b/apps/web/src/api/health/consultations.ts @@ -41,6 +41,30 @@ export interface CreateMessageReq { content: string; } +// --- 咨询联动请求类型 --- +export interface CreateFollowUpFromConsultationReq { + follow_up_type: string; + planned_date: string; + assigned_to?: string; + content_template?: string; +} + +export interface FollowUpFromConsultationResp { + task_id: string; + session_id: string; + patient_id: string; +} + +export interface TriggerAiAnalysisReq { + analysis_type?: string; +} + +export interface AiAnalysisTriggeredResp { + session_id: string; + patient_id: string; + analysis_type: string; +} + // --- API --- export const consultationApi = { listSessions: async (params: { @@ -134,4 +158,28 @@ export const consultationApi = { }>('/health/consultation-sessions/export', { params }); return data.data; }, + + /** 从咨询会话创建随访任务 */ + createFollowUpFromSession: async ( + sessionId: string, + req: CreateFollowUpFromConsultationReq, + ) => { + const { data } = await client.post<{ + success: boolean; + data: FollowUpFromConsultationResp; + }>(`/health/consultation-sessions/${sessionId}/follow-up`, req); + return data.data; + }, + + /** 从咨询会话触发 AI 分析 */ + triggerAiAnalysisFromSession: async ( + sessionId: string, + req?: TriggerAiAnalysisReq, + ) => { + const { data } = await client.post<{ + success: boolean; + data: AiAnalysisTriggeredResp; + }>(`/health/consultation-sessions/${sessionId}/ai-analysis`, req ?? {}); + return data.data; + }, }; diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index a394a6b..c129b08 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { Button, Input, Spin, Popconfirm, message, Typography } from "antd"; +import { Button, Input, Spin, Popconfirm, message, Typography, Modal, Form, Select, DatePicker } from "antd"; import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined, + ScheduleOutlined, + RobotOutlined, } from "@ant-design/icons"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; +import dayjs from "dayjs"; import { consultationApi, type Session, @@ -61,11 +64,20 @@ export default function ConsultationDetail() { const [inputText, setInputText] = useState(""); const [hasMore, setHasMore] = useState(false); + // Follow-up modal state + const [followUpModalOpen, setFollowUpModalOpen] = useState(false); + const [followUpLoading, setFollowUpLoading] = useState(false); + const [followUpForm] = Form.useForm(); + + // AI analysis state + const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false); + const chatEndRef = useRef(null); const shouldScrollRef = useRef(true); const isDark = useThemeMode(); + const navigate = useNavigate(); // --- Fetch session info --- const fetchSession = useCallback(async () => { @@ -207,6 +219,46 @@ export default function ConsultationDetail() { } }; + // --- Create follow-up from consultation --- + const handleCreateFollowUp = async () => { + if (!sessionId) return; + try { + const values = await followUpForm.validateFields(); + setFollowUpLoading(true); + await consultationApi.createFollowUpFromSession(sessionId, { + follow_up_type: values.follow_up_type, + planned_date: values.planned_date.format("YYYY-MM-DD"), + content_template: values.content_template || undefined, + }); + message.success("随访任务已创建"); + setFollowUpModalOpen(false); + followUpForm.resetFields(); + } catch (err: unknown) { + if (err && typeof err === "object" && "errorFields" in err) { + // Form validation error, ignore + return; + } + message.error("创建随访任务失败"); + } finally { + setFollowUpLoading(false); + } + }; + + // --- Trigger AI analysis --- + const handleTriggerAiAnalysis = async () => { + if (!sessionId) return; + setAiAnalysisLoading(true); + try { + const result = await consultationApi.triggerAiAnalysisFromSession(sessionId); + message.success(`AI 分析已触发(类型: ${result.analysis_type})`); + navigate("/health/ai-analysis"); + } catch { + message.error("触发 AI 分析失败"); + } finally { + setAiAnalysisLoading(false); + } + }; + // --- Render a single message bubble --- const renderMessage = (msg: Message) => { const align = ROLE_ALIGN[msg.sender_role] ?? "flex-start"; @@ -319,6 +371,30 @@ export default function ConsultationDetail() { ID: {sessionId.slice(0, 8)} )} + {session && !isClosed && ( + + + + )} + {session && !isClosed && ( + + + + )} {session && !isClosed && ( + + {/* Follow-up creation modal */} + { + setFollowUpModalOpen(false); + followUpForm.resetFields(); + }} + confirmLoading={followUpLoading} + okText="创建" + cancelText="取消" + destroyOnClose + > +
+ +