feat(health+ai): P2 咨询联动 + AI 巡检消费 — 全链路打通
业务链路打通 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 通过
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,24 @@ import GuestGuard from '@/components/GuestGuard';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
/** 读取当前页面 URL 中的查询参数 */
|
||||
function getQueryParams(): Record<string, string> {
|
||||
try {
|
||||
const instance = Taro.getCurrentInstance();
|
||||
const params = instance?.router?.params;
|
||||
if (!params) return {};
|
||||
const result: Record<string, string> = {};
|
||||
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() {
|
||||
{/* 副标题 */}
|
||||
<Text className='consultation-subtitle'>随时随地,连接专业医生</Text>
|
||||
|
||||
{/* Alert context banner */}
|
||||
{alertContext && (
|
||||
<ContentCard className='consultation-alert-banner'>
|
||||
<View className='consultation-alert-banner-inner'>
|
||||
<Text className='consultation-alert-banner-icon'>⚠</Text>
|
||||
<Text className='consultation-alert-banner-text'>
|
||||
来自告警: {alertContext.alertTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* 发起咨询按钮 */}
|
||||
<View
|
||||
className='consultation-create-btn'
|
||||
|
||||
@@ -98,3 +98,31 @@
|
||||
color: $tx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid $bd;
|
||||
}
|
||||
|
||||
.alert-consult-btn {
|
||||
padding: 8px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: var(--tk-pri);
|
||||
}
|
||||
|
||||
.alert-consult-btn-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,19 @@ export default function PatientAlerts() {
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-title'>{item.title}</Text>
|
||||
{item.message && (
|
||||
<Text className='alert-message'>{item.message}</Text>
|
||||
)}
|
||||
<View className='alert-actions'>
|
||||
<View
|
||||
className='alert-consult-btn'
|
||||
onClick={() => safeNavigateTo(
|
||||
`/pages/consultation/index?context=alert&alert_id=${item.id}&alert_title=${encodeURIComponent(item.title)}`,
|
||||
)}
|
||||
>
|
||||
<Text className='alert-consult-btn-text'>在线咨询</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement>(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)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{session && !isClosed && (
|
||||
<AuthButton code="health.consultation.manage">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ScheduleOutlined />}
|
||||
onClick={() => setFollowUpModalOpen(true)}
|
||||
>
|
||||
创建随访
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
{session && !isClosed && (
|
||||
<AuthButton code="health.consultation.manage">
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
icon={<RobotOutlined />}
|
||||
loading={aiAnalysisLoading}
|
||||
onClick={handleTriggerAiAnalysis}
|
||||
>
|
||||
AI 分析
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
{session && !isClosed && (
|
||||
<AuthButton code="health.consultation.manage">
|
||||
<Popconfirm
|
||||
@@ -411,6 +487,56 @@ export default function ConsultationDetail() {
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{/* Follow-up creation modal */}
|
||||
<Modal
|
||||
title="创建随访任务"
|
||||
open={followUpModalOpen}
|
||||
onOk={handleCreateFollowUp}
|
||||
onCancel={() => {
|
||||
setFollowUpModalOpen(false);
|
||||
followUpForm.resetFields();
|
||||
}}
|
||||
confirmLoading={followUpLoading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={followUpForm}
|
||||
layout="vertical"
|
||||
initialValues={{ follow_up_type: "phone" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="follow_up_type"
|
||||
label="随访类型"
|
||||
rules={[{ required: true, message: "请选择随访类型" }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "电话随访", value: "phone" },
|
||||
{ label: "在线随访", value: "online" },
|
||||
{ label: "上门随访", value: "home_visit" },
|
||||
]}
|
||||
placeholder="请选择随访类型"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="planned_date"
|
||||
label="计划日期"
|
||||
rules={[{ required: true, message: "请选择计划日期" }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: "100%" }}
|
||||
disabledDate={(current) => current && current < dayjs().startOf("day")}
|
||||
placeholder="选择随访日期"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="content_template" label="随访内容(可选)">
|
||||
<Input.TextArea rows={3} placeholder="输入随访内容模板" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user