Files
xk-admin/apps/web-antd/src/views/business/chat/components/MessageInput.vue

939 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onMounted, onUnmounted, provide, ref } from 'vue';
import { useRoute } from 'vue-router';
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { usePrescriptionStore } from '#/store/prescription';
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
import TransferPrescriptionViewDrawer from '#/views/doctor/online-consultation/components/TransferPrescriptionViewDrawer.vue';
import { useFileUpload } from '../composables/useFileUpload.ts';
import { useRecording } from '../composables/useRecording.ts';
import { useChatStore } from '../stores/chat.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore } from '../stores/user.ts';
import { sendMessage } from '../utils/request.ts';
// 定义 props
const props = defineProps({
currentFriend: {
type: Object,
default: () => ({}),
},
});
// 定义 emits
const emit = defineEmits([
'sendMessage',
'openPrescription', // 添加开方事件
'end-consultation-success', // 结束接诊成功事件
'transfer-import-success', // 转接方导入成功事件
]);
// 根据路由判断是在线复诊-药店还是在线复诊-诊所
const route = useRoute();
const isClinicModule = computed(() => {
return route.path.includes('online-consultation-clinic');
});
import {
endConsultation as endConsultationClinic,
getDrugsByRegisterId as getDrugsByRegisterIdClinic,
} from '#/views/doctor/online-consultation-clinic/api/index';
import QuickReplyBubblesClinic from '#/views/doctor/online-consultation-clinic/components/QuickReplyBubbles.vue';
// 动态导入对应的API和组件
import {
endConsultation as endConsultationPharmacy,
getDrugsByRegisterId as getDrugsByRegisterIdPharmacy,
} from '#/views/doctor/online-consultation/api/index';
import QuickReplyBubblesPharmacy from '#/views/doctor/online-consultation/components/QuickReplyBubbles.vue';
// 根据模块选择对应的API和组件
const endConsultation = computed(() => {
return isClinicModule.value ? endConsultationClinic : endConsultationPharmacy;
});
const getDrugsByRegisterId = computed(() => {
return isClinicModule.value
? getDrugsByRegisterIdClinic
: getDrugsByRegisterIdPharmacy;
});
const QuickReplyBubbles = computed(() => {
return isClinicModule.value
? QuickReplyBubblesClinic
: QuickReplyBubblesPharmacy;
});
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
import CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const prescriptionStore = usePrescriptionStore();
const messageText = ref('');
const messageInputRef = ref(null);
const showEmojiPicker = ref(false);
const showTransferDrawer = ref(false);
const transferRegisterId = ref(null);
// 使用文件上传组合式函数
const {
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload,
cancelUpload,
} = useFileUpload();
// 使用录音组合式函数
const { isRecording, startRecording, stopRecording } = useRecording();
// 发送上传的文件
const sendUploadedFile = () => {
if (!uploadPreview.value) return;
// 消息类型映射
const messageTypeMap = {
text: 0,
image: 1,
audio: 2,
video: 3,
prescription: 4,
'medical-record': 5,
'video-call': 6,
'audio-call': 7,
file: 8,
};
const messageObj = {
message_type: messageTypeMap[uploadPreview.value.type] || 0,
message_content: uploadPreview.value.url,
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
created_at: Date.now(),
sender_user_id: `doctor-${userStore.currentUser.doctor_id}`,
receiver_user_id: chatStore.currentFriend.id,
room_id: chatStore.currentFriend.room_id,
read: true,
duration: 0,
isSent: true, // 标记为自己发送的消息,确保显示在右侧
};
chatStore.addMessage(messageObj, userStore.currentUser.doctor_id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.doctor_id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id, // 添加房间ID
type: uploadPreview.value.type,
content: uploadPreview.value.url,
}).catch((error) => {
console.error('发送文件失败:', error);
message.error('文件发送失败');
});
uploadPreview.value = null;
};
// 提供给子组件
provide('uploadPreview', uploadPreview);
provide('cancelUpload', cancelUpload);
provide('sendFile', sendUploadedFile);
// 发送文本消息
const sendTextMessage = async () => {
const content = messageText.value.trim();
if (!content && !uploadPreview.value) return;
let messageContent = content;
let messageType = 'text';
// 如果有文件预览,发送文件
if (uploadPreview.value) {
messageContent = uploadPreview.value.url;
messageType = uploadPreview.value.type;
uploadPreview.value = null;
}
// 消息类型映射
const messageTypeMap = {
text: 0,
image: 1,
audio: 2,
video: 3,
prescription: 4,
'medical-record': 5,
'video-call': 6,
'audio-call': 7,
file: 8,
register: 10,
'patient-experience': 11,
'product-card': 12,
'end-consultation': 13,
};
// 创建消息对象 - 使用数据库字段名
const messageObj = {
message_type: messageTypeMap[messageType] || 0,
message_content: messageContent,
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
created_at: Date.now(),
sender_user_id: `doctor-${userStore.currentUser.doctor_id}`,
receiver_user_id: chatStore.currentFriend.id,
room_id: chatStore.currentFriend.room_id,
isSent: true,
read: true,
duration: 0,
};
// return console.log('发送消息:' + userStore.currentUser.doctor_id, messageObj);
// 添加到本地消息列表
chatStore.addMessage(messageObj, userStore.currentUser.doctor_id);
// 发送到服务器
try {
await sendMessage({
senderId: userStore.currentUser.doctor_id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
type: messageType,
content: messageContent,
});
} catch (error) {
console.error('发送消息失败:', error);
message.error('消息发送失败');
}
// 清空输入框
messageText.value = '';
// 重置焦点
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 处理键盘事件
const handleKeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendTextMessage();
}
};
// 处理粘贴文件
const handlePasteFile = (file) => {
console.log('检测到粘贴文件:', file);
// 根据文件类型自动选择处理方式
if (file.type.startsWith('image/')) {
handleFileFromPaste(file, 'image');
} else if (file.type.startsWith('video/')) {
handleFileFromPaste(file, 'video');
} else {
message.warning('不支持的文件类型');
}
};
// 处理粘贴的文件
const handleFileFromPaste = async (file, type) => {
// 文件大小检查
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024;
message.error(
`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
);
return;
}
let uploadFile = file;
let compressMeta = null;
if (type === 'image') {
const prepared = await prepareImageForUpload(file);
if (!prepared.file) {
return;
}
uploadFile = prepared.file;
compressMeta = prepared.meta;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadPreview.value = {
type,
url: e.target.result,
name: file.name,
size: uploadFile.size,
file: uploadFile,
compressMeta,
};
});
reader.onerror = (error) => {
console.error('文件读取失败:', error);
message.error('文件读取失败,请重试');
};
reader.readAsDataURL(file);
};
// 切换表情选择器
const toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
};
// 插入表情
const insertEmoji = (emoji) => {
messageText.value += emoji;
showEmojiPicker.value = false;
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 监听录音完成事件
const handleAudioRecorded = (event) => {
const { url, duration } = event.detail;
const messageObj = {
message_type: 2, // audio
message_content: url,
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
created_at: Date.now(),
sender_user_id: `doctor-${userStore.currentUser.doctor_id}`,
receiver_user_id: chatStore.currentFriend.id,
room_id: chatStore.currentFriend.room_id,
isSent: true,
read: true,
duration,
};
chatStore.addMessage(messageObj, userStore.currentUser.doctor_id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.doctor_id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
isSent: true,
type: 'audio',
content: url,
duration,
}).catch((error) => {
console.error('发送音频失败:', error);
message.error('音频发送失败');
});
};
onMounted(() => {
window.addEventListener('audioRecorded', handleAudioRecorded);
// 点击外部关闭表情选择器
document.addEventListener('click', (event) => {
if (
!event.target.closest('.emoji-picker') &&
!event.target.closest('.function-btn')
) {
showEmojiPicker.value = false;
}
});
});
onUnmounted(() => {
window.removeEventListener('audioRecorded', handleAudioRecorded);
});
// 开方功能触发函数
const handleOpenPrescription = () => {
// 获取当前会话的register_id,这里假设可以通过某种方式获取
// 例如从聊天存储或props中获取
const registerId = chatStore.currentFriend?.register_id || 66;
if (registerId) {
emit('openPrescription', registerId);
} else {
console.warn('无法获取当前会话的register_id');
// 可以添加一个提示或使用默认值
emit('openPrescription', 0);
}
};
// 处理快速回复选择
const handleQuickReplySelect = (content) => {
// 设置输入框内容并发送
messageText.value = content;
sendTextMessage();
};
// 判断是否显示在线复诊相关按钮
const showOnlineConsultationButtons = computed(() => {
console.log('检查是否显示在线复诊相关按钮:', chatStore.currentFriend);
// 检查是否有 register_id,如果有则说明是在线复诊场景
return !!chatStore.currentFriend?.register_id;
});
// 将患者就诊经历中的药品加入处方单
const handleAddPatientProductsToPrescription = async () => {
// 检查处方store是否已初始化
if (!prescriptionStore.currentRegisterId) {
const registerId = chatStore.currentFriend?.register_id;
if (registerId) {
// 根据模块选择对应的存储前缀
const storagePrefix = isClinicModule.value
? 'onlineConsultationClinic-'
: 'onlineConsultation-';
prescriptionStore.initializePrescription(
registerId,
false,
storagePrefix,
);
} else {
message.warning('请先接诊患者');
return;
}
}
// 获取诊所ID
let storeId = prescriptionStore.myStoreId;
if (!storeId || storeId === 0) {
if (
prescriptionStore.myStoreList &&
prescriptionStore.myStoreList.length > 0
) {
storeId = prescriptionStore.myStoreList[0].id;
} else {
message.warning('请先选择诊所');
return;
}
}
try {
// 根据 register_id 获取药品信息
const res = await getDrugsByRegisterId.value(
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id,
storeId,
);
const drugList = res?.result || res?.data || res || [];
if (!drugList || drugList.length === 0) {
message.warning('未找到药品信息');
return;
}
// 遍历药品列表,添加到处方单
let addedCount = 0;
for (const drugData of drugList) {
const success = prescriptionStore.addProducts(drugData);
if (success) {
addedCount++;
}
}
if (addedCount > 0) {
message.success(`已成功添加 ${addedCount} 个药品到处方单`);
// 触发打开处方模态框事件
emit(
'openPrescription',
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id,
);
} else {
message.warning('所有药品已在处方中');
}
} catch (error) {
console.error('添加药品失败:', error);
message.error('添加药品失败,请重试');
}
};
// 结束问诊
const handleEndConsultation = () => {
const registerId = chatStore.currentFriend?.register_id;
if (!registerId) {
message.warning('患者信息不完整,无法结束接诊');
return;
}
const patientName = chatStore.currentFriend?.nick_name || '患者';
Modal.confirm({
title: '确认结束接诊',
content: `确定要结束${patientName}的复诊吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
// 使用计算属性获取对应的endConsultation函数
const endConsultationFn = endConsultation.value;
await endConsultationFn({
register_id: registerId,
doctor_id: userStore.currentUser?.doctor_id || '39',
reason: '医生主动结束',
});
message.success('结束接诊成功');
emit('end-consultation-success');
} catch (error) {
console.error('结束接诊失败:', error);
message.error('结束接诊失败,请重试');
}
},
});
};
// 打开转接方查看抽屉
const handleOpenTransferView = async () => {
const registerId = chatStore.currentFriend?.register_id;
if (!registerId) {
message.warning('患者信息不完整,无法查看转接方');
return;
}
// 检查是否有转接方
try {
const res = await getTransferPrescriptionByRegisterApi(registerId);
const data = res?.result || res?.data || res;
if (data && data.id) {
transferRegisterId.value = registerId;
showTransferDrawer.value = true;
} else {
message.warning('该患者没有转接方信息');
}
} catch (error) {
console.error('检查转接方信息失败:', error);
message.error('检查转接方信息失败,请重试');
}
};
// 处理转接方导入成功
const handleTransferImportSuccess = (prescriptionData) => {
// 触发打开处方模态框的事件
emit('transfer-import-success', prescriptionData);
emit(
'openPrescription',
transferRegisterId.value || chatStore.currentFriend?.register_id,
);
};
</script>
<template>
<div
class="message-input-container flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
>
<!-- 快速回复气泡(仅在线复诊场景显示) -->
<component
:is="QuickReplyBubbles"
v-if="chatStore.currentFriend"
doctor-id="32"
@select-reply="handleQuickReplySelect"
/>
<!-- 文件上传预览 -->
<FileUploadPreview v-if="uploadPreview" />
<!-- 表情选择器 -->
<EmojiPicker v-if="showEmojiPicker" class="mb-10" @select="insertEmoji" />
<!-- 工具栏 -->
<div class="toolbar">
<button
class="function-btn prescription-button"
@click="handleOpenPrescription"
title="开方"
>
<i class="fas fa-file-medical"></i>
<!-- <i class="fas fa-prescription"></i>-->
</button>
<button
class="function-btn"
@click="triggerFileInput('image')"
title="发送图片"
>
<i class="fas fa-image"></i>
</button>
<!-- <button-->
<!-- class="function-btn"-->
<!-- @click="triggerFileInput('video')"-->
<!-- title="发送视频"-->
<!-- >-->
<!-- <i class="fas fa-video"></i>-->
<!-- </button>-->
<button
:class="{ active: showEmojiPicker }"
class="function-btn"
title="选择表情"
@click="toggleEmojiPicker"
>
<i class="fas fa-smile"></i>
</button>
<!-- <button-->
<!-- :class="{ recording: isRecording }"-->
<!-- class="function-btn record-btn"-->
<!-- title="按住录音"-->
<!-- @mousedown="startRecording"-->
<!-- @mouseleave="stopRecording"-->
<!-- @mouseup="stopRecording"-->
<!-- @touchend="stopRecording"-->
<!-- @touchstart="startRecording"-->
<!-- >-->
<!-- <i class="fas fa-microphone"></i>-->
<!-- </button>-->
<!-- 添加药品按钮(仅在线复诊场景显示) -->
<button
v-if="showOnlineConsultationButtons"
class="function-btn"
title="将患者就诊经历中的药品加入处方单"
@click="handleAddPatientProductsToPrescription"
>
<PlusOutlined />
</button>
<!-- 转接方查看按钮(仅在线复诊场景显示) -->
<button
v-if="showOnlineConsultationButtons"
class="function-btn transfer-view-btn"
title="查看转接方"
@click="handleOpenTransferView"
>
<i class="fas fa-exchange-alt"></i>
</button>
<!-- 结束问诊按钮(仅在线复诊场景显示) -->
<button
v-if="showOnlineConsultationButtons"
class="function-btn end-consultation-btn"
title="结束问诊"
@click="handleEndConsultation"
>
<CloseOutlined />
</button>
</div>
<div class="input-container">
<!-- 输入框区域 -->
<div class="input-section">
<CustomTextarea
ref="messageInputRef"
v-model="messageText"
:auto-resize="true"
:detect-paste="true"
:max-rows="4"
class="message-textarea"
placeholder="输入消息... (支持拖拽文件)"
@keydown="handleKeydown"
@paste-file="handlePasteFile"
/>
<button
:disabled="!messageText.trim() && !uploadPreview"
class="send-btn"
title="发送消息"
@click="sendTextMessage"
>
<i class="fas fa-paper-plane"></i>
<div class="send-ripple"></div>
</button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
ref="imageInput"
accept="image/*"
class="hidden"
type="file"
@change="handleFileUpload"
/>
<input
ref="videoInput"
accept="video/*"
class="hidden"
type="file"
@change="handleFileUpload"
/>
<!-- 转接方查看抽屉 -->
<TransferPrescriptionViewDrawer
v-model:visible="showTransferDrawer"
:register-id="transferRegisterId"
@import-success="handleTransferImportSuccess"
/>
</div>
</template>
<style scoped>
.message-input-container {
position: relative;
padding: var(--chat-input-pad, 12px);
background: linear-gradient(135deg, #fff 0%, #f8fafc 100%);
}
.dark .message-input-container {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
}
.input-container {
display: flex;
flex-direction: column;
gap: var(--chat-input-gap, 10px);
}
.toolbar {
display: flex;
align-items: center;
padding: var(--chat-toolbar-pad, 4px) 0;
border-top: none;
}
.dark .toolbar {
border-top-color: #374151;
}
.function-btn {
display: flex;
align-items: center;
padding: 5px 10px;
margin-right: 5px;
color: #666;
cursor: pointer;
background: none;
border: none;
border-radius: 4px;
}
.dark .function-btn {
color: #9ca3af;
}
.function-btn:hover {
background-color: #f0f0f0;
}
.dark .function-btn:hover {
background-color: #374151;
}
.prescription-button {
font-weight: 500;
color: #455cda;
}
.prescription-button:hover {
background-color: #e6e9ff;
}
.end-consultation-btn {
font-weight: 500;
color: #ff4d4f;
}
.end-consultation-btn:hover {
color: #ff4d4f;
background-color: #fff1f0;
}
.dark .end-consultation-btn:hover {
color: #ff4d4f;
background-color: rgb(255 77 79 / 20%);
}
.transfer-view-btn {
font-weight: 500;
color: #f97316;
}
.transfer-view-btn:hover {
color: #f97316;
background-color: #fff7ed;
}
.dark .transfer-view-btn:hover {
color: #f97316;
background-color: rgb(249 115 22 / 20%);
}
.function-buttons {
display: flex;
gap: 12px;
align-items: center;
}
.function-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: var(--chat-fn-btn, 36px);
height: var(--chat-fn-btn, 36px);
padding: 0;
margin-right: 6px;
overflow: hidden;
font-size: var(--chat-fn-font, 15px);
color: #64748b;
cursor: pointer;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
border: none;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.function-btn:hover {
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
box-shadow: 0 8px 20px rgb(67 97 238 / 30%);
transform: translateY(-2px) scale(1.05);
}
.function-btn.active {
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
transform: scale(1.1);
}
.function-btn.recording {
color: white;
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
animation: pulse-recording 1s infinite;
}
@keyframes pulse-recording {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.recording-wave {
position: absolute;
top: 50%;
left: 50%;
width: 60px;
height: 60px;
border: 2px solid rgb(255 255 255 / 30%);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: wave-expand 1.5s infinite;
}
@keyframes wave-expand {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.8);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
.btn-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgb(255 255 255 / 30%);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.function-btn:active .btn-ripple {
width: 100px;
height: 100px;
}
.dark .function-btn {
color: #9ca3af;
background: linear-gradient(135deg, #374151, #4b5563);
}
.dark .function-btn:hover {
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.input-section {
position: relative;
display: flex;
gap: var(--chat-input-gap, 10px);
align-items: end;
}
.message-textarea {
flex: 1;
}
.send-btn {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--chat-send-btn, 40px);
height: var(--chat-send-btn, 40px);
overflow: hidden;
font-size: var(--chat-send-font, 15px);
color: white;
cursor: pointer;
background: linear-gradient(135deg, #4361ee, #3f37c9);
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgb(67 97 238 / 30%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.send-btn:hover:not(:disabled) {
box-shadow: 0 8px 20px rgb(67 97 238 / 40%);
transform: translateY(-2px) scale(1.05);
}
.send-btn:disabled {
cursor: not-allowed;
background: #94a3b8;
box-shadow: none;
transform: none;
}
.send-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgb(255 255 255 / 40%);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.send-btn:active:not(:disabled) .send-ripple {
width: 80px;
height: 80px;
}
.hidden {
display: none;
}
</style>