2026-01-29 18:03:40 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<view class="ai-assistant-buttons">
|
|
|
|
|
|
<view
|
|
|
|
|
|
v-for="button in buttons"
|
|
|
|
|
|
:key="button.id"
|
|
|
|
|
|
class="ai-button"
|
|
|
|
|
|
:class="{ loading: button.loading }"
|
|
|
|
|
|
@click="handleButtonClick(button)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<image class="button-icon" :src="button.icon" mode="aspectFit" />
|
|
|
|
|
|
<text class="button-text">{{
|
|
|
|
|
|
button.loading && button.loadingText ? button.loadingText : button.text
|
|
|
|
|
|
}}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 病历类型选择弹窗 -->
|
|
|
|
|
|
<medical-case-type-selector
|
|
|
|
|
|
ref="typeSelectorRef"
|
|
|
|
|
|
@select="handleCaseTypeSelect"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 进度显示弹窗 -->
|
2026-01-29 18:44:34 +08:00
|
|
|
|
<medical-case-progress
|
|
|
|
|
|
ref="progressRef"
|
|
|
|
|
|
@regenerate="handleRegenerateFromProgress"
|
|
|
|
|
|
@next="handleNextFromProgress"
|
|
|
|
|
|
/>
|
2026-01-29 18:03:40 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, onMounted, onUnmounted } from "vue";
|
|
|
|
|
|
import request from "@/utils/http.js";
|
|
|
|
|
|
import api from "@/utils/api.js";
|
|
|
|
|
|
import MedicalCaseTypeSelector from "./medical-case-type-selector.vue";
|
|
|
|
|
|
import MedicalCaseProgress from "./medical-case-progress.vue";
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
groupId: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
patientAccountId: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
corpId: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
customerId: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: "",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const emit = defineEmits(["streamText", "clearInput"]);
|
|
|
|
|
|
|
|
|
|
|
|
const typeSelectorRef = ref(null);
|
|
|
|
|
|
const progressRef = ref(null);
|
|
|
|
|
|
const buttons = ref([
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "followUp",
|
|
|
|
|
|
text: "追问病情",
|
|
|
|
|
|
loadingText: "AI分析中,正在为您生成追问建议…",
|
|
|
|
|
|
icon: "/static/icon/zhuiwen.png",
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "aiAssistant",
|
|
|
|
|
|
text: "开启AI助手",
|
|
|
|
|
|
icon: "/static/icon/kaiqiAI.png",
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "supplementRecord",
|
|
|
|
|
|
text: "补充病历",
|
|
|
|
|
|
icon: "/static/icon/buchong.png",
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理按钮点击
|
|
|
|
|
|
const handleButtonClick = async (button) => {
|
|
|
|
|
|
if (button.loading) return;
|
|
|
|
|
|
|
|
|
|
|
|
switch (button.id) {
|
|
|
|
|
|
case "followUp":
|
|
|
|
|
|
await handleFollowUpInquiry(button);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "aiAssistant":
|
|
|
|
|
|
handleAIAssistant(button);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "supplementRecord":
|
|
|
|
|
|
handleSupplementRecord(button);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理追问病情
|
|
|
|
|
|
const handleFollowUpInquiry = async (button) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
button.loading = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有提供患者账号ID,先获取群组信息
|
|
|
|
|
|
let finalPatientAccountId = props.patientAccountId;
|
|
|
|
|
|
|
|
|
|
|
|
if (!finalPatientAccountId) {
|
|
|
|
|
|
// 从聊天记录中获取患者账号ID(非当前用户的发送者)
|
|
|
|
|
|
const chatRecordsResult = await api("getChatRecordsByGroupId", {
|
|
|
|
|
|
groupID: props.groupId,
|
|
|
|
|
|
count: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
chatRecordsResult.success &&
|
|
|
|
|
|
chatRecordsResult.data &&
|
|
|
|
|
|
chatRecordsResult.data.records
|
|
|
|
|
|
) {
|
|
|
|
|
|
const records = chatRecordsResult.data.records;
|
|
|
|
|
|
// 找到第一个非当前用户的发送者作为患者账号
|
|
|
|
|
|
const currentUserId = uni.getStorageSync("openid") || "";
|
|
|
|
|
|
const patientMessage = records.find(
|
|
|
|
|
|
(record) =>
|
|
|
|
|
|
record.From_Account && record.From_Account !== currentUserId
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (patientMessage) {
|
|
|
|
|
|
finalPatientAccountId = patientMessage.From_Account;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!finalPatientAccountId) {
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: "无法获取患者信息",
|
|
|
|
|
|
icon: "none",
|
|
|
|
|
|
});
|
|
|
|
|
|
button.loading = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 调用追问病情接口(不显示全局loading)
|
|
|
|
|
|
const result = await request(
|
|
|
|
|
|
{
|
|
|
|
|
|
url: "/getYoucanData/im",
|
|
|
|
|
|
data: {
|
|
|
|
|
|
type: "followUpInquiry",
|
|
|
|
|
|
groupId: props.groupId,
|
|
|
|
|
|
patientAccountId: finalPatientAccountId,
|
|
|
|
|
|
corpId: props.corpId,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
false
|
|
|
|
|
|
); // 第二个参数false表示不显示loading
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.data && result.data.suggestion) {
|
|
|
|
|
|
// 流式输出文本到输入框
|
|
|
|
|
|
streamTextToInput(result.data.suggestion);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: result.message || "获取追问建议失败",
|
|
|
|
|
|
icon: "none",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("追问病情失败:", error);
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: "操作失败,请重试",
|
|
|
|
|
|
icon: "none",
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
button.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 流式输出文本到输入框
|
|
|
|
|
|
const streamTextToInput = (text) => {
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 先清空输入框
|
|
|
|
|
|
emit("clearInput");
|
|
|
|
|
|
|
|
|
|
|
|
let currentIndex = 0;
|
|
|
|
|
|
const speed = 50; // 每个字符的延迟时间(毫秒)
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟一小段时间后开始流式输出,确保清空操作完成
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const streamInterval = setInterval(() => {
|
|
|
|
|
|
if (currentIndex < text.length) {
|
|
|
|
|
|
const char = text[currentIndex];
|
|
|
|
|
|
emit("streamText", char);
|
|
|
|
|
|
currentIndex++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearInterval(streamInterval);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, speed);
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理AI助手
|
|
|
|
|
|
const handleAIAssistant = (button) => {
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: "AI助手功能开发中",
|
|
|
|
|
|
icon: "none",
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理补充病历
|
|
|
|
|
|
const handleSupplementRecord = (button) => {
|
|
|
|
|
|
typeSelectorRef.value?.open();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理病历类型选择
|
|
|
|
|
|
const handleCaseTypeSelect = async (type) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 打开进度弹窗
|
|
|
|
|
|
progressRef.value?.open(type.id);
|
2026-01-29 18:44:34 +08:00
|
|
|
|
progressRef.value?.updateProgress(10);
|
2026-01-29 18:03:40 +08:00
|
|
|
|
|
2026-01-29 18:44:34 +08:00
|
|
|
|
// 调用补充病历接口(流式处理)
|
|
|
|
|
|
await requestWithStream({
|
2026-01-29 18:03:40 +08:00
|
|
|
|
url: "/getYoucanData/im",
|
|
|
|
|
|
data: {
|
|
|
|
|
|
type: "supplementMedicalCase",
|
|
|
|
|
|
groupId: props.groupId,
|
|
|
|
|
|
patientAccountId: props.patientAccountId || props.customerId,
|
|
|
|
|
|
corpId: props.corpId,
|
|
|
|
|
|
caseType: type.id,
|
|
|
|
|
|
},
|
2026-01-29 18:44:34 +08:00
|
|
|
|
onProgress: (data) => {
|
|
|
|
|
|
// 处理流式数据
|
|
|
|
|
|
handleStreamData(data, type.id);
|
|
|
|
|
|
},
|
|
|
|
|
|
onComplete: (finalData) => {
|
|
|
|
|
|
// 完成后跳转
|
|
|
|
|
|
handleComplete(finalData, type.id);
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => {
|
|
|
|
|
|
progressRef.value?.close();
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: error.message || "生成病历失败",
|
|
|
|
|
|
icon: "none",
|
2026-01-29 18:03:40 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-29 18:44:34 +08:00
|
|
|
|
});
|
2026-01-29 18:03:40 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("补充病历失败:", error);
|
|
|
|
|
|
progressRef.value?.close();
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: "操作失败,请重试",
|
|
|
|
|
|
icon: "none",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-29 18:44:34 +08:00
|
|
|
|
// 流式请求处理
|
|
|
|
|
|
const requestWithStream = async ({ url, data, onProgress, onComplete, onError }) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 调用接口时不显示全局 loading(第二个参数为 false)
|
|
|
|
|
|
const result = await request({
|
|
|
|
|
|
url,
|
|
|
|
|
|
data,
|
|
|
|
|
|
}, false);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.data) {
|
|
|
|
|
|
// 模拟流式处理(如果后端返回的是完整数据)
|
|
|
|
|
|
const extractedData = result.data.extractedData || {};
|
|
|
|
|
|
|
|
|
|
|
|
// 逐个字段动态显示(包括空值字段)
|
|
|
|
|
|
let progressValue = 20;
|
|
|
|
|
|
const fields = Object.entries(extractedData);
|
|
|
|
|
|
const delay = 300; // 每个字段显示间隔
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < fields.length; i++) {
|
|
|
|
|
|
const [key, value] = fields[i];
|
|
|
|
|
|
|
|
|
|
|
|
// 显示所有字段,包括空值(会在组件中显示为"暂无")
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
|
|
|
|
onProgress({ key, value });
|
|
|
|
|
|
progressValue += Math.floor(60 / fields.length);
|
|
|
|
|
|
progressRef.value?.updateProgress(Math.min(progressValue, 80));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 完成
|
|
|
|
|
|
onComplete(result.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
onError(new Error(result.message || "请求失败"));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
onError(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理流式数据
|
|
|
|
|
|
const handleStreamData = (data, caseType) => {
|
|
|
|
|
|
const { key, value } = data;
|
|
|
|
|
|
|
|
|
|
|
|
// 添加检测到的信息
|
|
|
|
|
|
progressRef.value?.addDetectedInfo(key, value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理完成
|
|
|
|
|
|
const handleComplete = (finalData, caseType) => {
|
|
|
|
|
|
progressRef.value?.updateProgress(90);
|
|
|
|
|
|
progressRef.value?.setGenerating(true);
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟后完成
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
progressRef.value?.updateProgress(100);
|
|
|
|
|
|
progressRef.value?.setGenerating(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟后显示操作按钮(不自动跳转)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
progressRef.value?.setCompleted(true, finalData);
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}, 800);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理从进度弹窗点击重新生成
|
|
|
|
|
|
const handleRegenerateFromProgress = (data) => {
|
|
|
|
|
|
const type = { id: data.caseType };
|
|
|
|
|
|
handleCaseTypeSelect(type);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理从进度弹窗点击下一步
|
|
|
|
|
|
const handleNextFromProgress = (data) => {
|
|
|
|
|
|
// 跳转到病历填写页面
|
|
|
|
|
|
uni.navigateTo({
|
|
|
|
|
|
url: `/pages/case/medical-case-form?caseType=${data.caseType}&customerId=${
|
|
|
|
|
|
props.customerId || props.patientAccountId
|
|
|
|
|
|
}&groupId=${props.groupId}&formData=${encodeURIComponent(
|
|
|
|
|
|
JSON.stringify(data.data?.extractedData || {})
|
|
|
|
|
|
)}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-29 18:03:40 +08:00
|
|
|
|
// 监听重新生成事件
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
uni.$on("regenerateMedicalCase", handleRegenerateMedicalCase);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
uni.$off("regenerateMedicalCase", handleRegenerateMedicalCase);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const handleRegenerateMedicalCase = (data) => {
|
|
|
|
|
|
const type = { id: data.caseType };
|
|
|
|
|
|
handleCaseTypeSelect(type);
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.ai-assistant-buttons {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16rpx;
|
|
|
|
|
|
padding: 16rpx 24rpx;
|
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
|
border-bottom: 1rpx solid #e5e5e5;
|
|
|
|
|
|
|
|
|
|
|
|
.ai-button {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8rpx;
|
|
|
|
|
|
padding: 12rpx 20rpx;
|
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
|
border: 1rpx solid #e0e0e0;
|
|
|
|
|
|
border-radius: 40rpx;
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
|
|
|
|
|
|
&.loading {
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
.loading-icon {
|
|
|
|
|
|
width: 32rpx;
|
|
|
|
|
|
height: 32rpx;
|
|
|
|
|
|
border: 3rpx solid #e0e0e0;
|
|
|
|
|
|
border-top-color: #1890ff;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
animation: spin 1s linear infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
|
background-color: #f0f0f0;
|
|
|
|
|
|
transform: scale(0.98);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-icon {
|
|
|
|
|
|
width: 32rpx;
|
|
|
|
|
|
height: 32rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-text {
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
color: #333333;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes spin {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
transform: rotate(0deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
transform: rotate(360deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|