454 lines
12 KiB
Vue
454 lines
12 KiB
Vue
|
|
<template>
|
|||
|
|
<view class="input-section">
|
|||
|
|
<view class="input-toolbar">
|
|||
|
|
<view @click="toggleVoiceInput" class="voice-toggle-btn">
|
|||
|
|
<uni-icons v-if="showVoiceInput" fontFamily="keyboard" :size="28">{{ '' }}</uni-icons>
|
|||
|
|
<uni-icons v-else type="mic" size="28" color="#666" />
|
|||
|
|
</view>
|
|||
|
|
<view class="input-area">
|
|||
|
|
<input v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
|
|||
|
|
@confirm="sendTextMessage" @focus="handleInputFocus" />
|
|||
|
|
<input v-else class="voice-input-btn" :class="{ recording: isRecording }" @touchstart="startRecord"
|
|||
|
|
@touchmove="onRecordTouchMove" @touchend="stopRecord" @touchcancel="cancelRecord" :placeholder="isRecording ? '松开发送' : '按住说话'" disabled>
|
|||
|
|
</input>
|
|||
|
|
</view>
|
|||
|
|
<button v-if="inputText.trim()" class="send-btn" @click="sendTextMessage">
|
|||
|
|
发送
|
|||
|
|
</button>
|
|||
|
|
<view v-else class="plus-btn" @click="toggleMorePanel()">
|
|||
|
|
<uni-icons type="plusempty" size="28" color="#666" />
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<view class="more-panel" v-if="showMorePanel">
|
|||
|
|
<view v-for="btn in morePanelButtons" :key="btn.text" class="more-btn" @click="btn.action">
|
|||
|
|
<image :src="btn.icon" class="more-icon" mode="aspectFit"></image>
|
|||
|
|
<text>{{ btn.text }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 录音遮罩层 -->
|
|||
|
|
<view v-if="isRecording" class="recording-overlay">
|
|||
|
|
<view class="recording-modal" :class="{ 'cancel-mode': isCancelMode }">
|
|||
|
|
<view class="recording-icon-container">
|
|||
|
|
<view v-if="!isCancelMode" class="wave-circle wave-1"></view>
|
|||
|
|
<view v-if="!isCancelMode" class="wave-circle wave-2"></view>
|
|||
|
|
<view v-if="!isCancelMode" class="wave-circle wave-3"></view>
|
|||
|
|
<view class="mic-icon-wrapper" :class="{ 'cancel-icon': isCancelMode }">
|
|||
|
|
<uni-icons v-if="!isCancelMode" type="mic-filled" size="60" color="#fff" />
|
|||
|
|
<uni-icons v-else type="closeempty" size="60" color="#fff" />
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<view class="recording-text" :class="{ 'cancel-text': isCancelMode }">
|
|||
|
|
{{ isCancelMode ? '松开手指,取消录音' : '正在录音...' }}
|
|||
|
|
</view>
|
|||
|
|
<view v-if="!isCancelMode" class="recording-hint">松开发送,上滑取消</view>
|
|||
|
|
<view v-if="!isCancelMode" class="recording-duration">{{ recordingDuration }}s</view>
|
|||
|
|
<view v-else class="cancel-hint">
|
|||
|
|
<uni-icons type="up" size="20" color="#ff4757" />
|
|||
|
|
<text>已上滑</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { computed, ref, onMounted, onUnmounted, nextTick } from "vue";
|
|||
|
|
import {
|
|||
|
|
chooseImage,
|
|||
|
|
takePhoto as takePhotoUtil,
|
|||
|
|
initRecorderManager as initRecorderManagerUtil,
|
|||
|
|
startRecord as startRecordUtil,
|
|||
|
|
stopRecord as stopRecordUtil,
|
|||
|
|
createCustomMessage,
|
|||
|
|
sendCustomMessage as sendCustomMessageUtil,
|
|||
|
|
sendMessage as sendMessageUtil,
|
|||
|
|
checkRecordingDuration,
|
|||
|
|
validateBeforeSend,
|
|||
|
|
} from "@/utils/chat-utils.js";
|
|||
|
|
|
|||
|
|
// Props
|
|||
|
|
const props = defineProps({
|
|||
|
|
timChatManager: { type: Object, required: true },
|
|||
|
|
patientInfo: { type: Object, default: () => ({}) },
|
|||
|
|
chatRoomBusiness: { type: Object, default: () => ({}) },
|
|||
|
|
formatTime: { type: Function, required: true },
|
|||
|
|
groupId: { type: String, default: '' },
|
|||
|
|
userId: { type: String, default: '' },
|
|||
|
|
corpId: { type: String, default: '' },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Emits
|
|||
|
|
const emit = defineEmits(["messageSent", "scrollToBottom", "endConsult"]);
|
|||
|
|
|
|||
|
|
// 输入相关状态
|
|||
|
|
const inputText = ref("");
|
|||
|
|
const showVoiceInput = ref(false);
|
|||
|
|
const showMorePanel = ref(false);
|
|||
|
|
const isRecording = ref(false);
|
|||
|
|
const recordingText = ref("录音中...");
|
|||
|
|
const cloudCustomData = computed(() => {
|
|||
|
|
const arr = [
|
|||
|
|
props.chatRoomBusiness.businessType,
|
|||
|
|
props.chatRoomBusiness.businessId,
|
|||
|
|
];
|
|||
|
|
return arr.filter(Boolean).join("|");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 录音相关扩展状态(特效 + 取消逻辑)
|
|||
|
|
const recordingDuration = ref(0);
|
|||
|
|
let recordingTimer = null;
|
|||
|
|
const isCancelMode = ref(false);
|
|||
|
|
let touchStartY = 0;
|
|||
|
|
const CANCEL_DISTANCE = 100;
|
|||
|
|
let discardRecording = false; // 取消后丢弃本次录音
|
|||
|
|
|
|||
|
|
// 录音管理器
|
|||
|
|
let recorderManager = null;
|
|||
|
|
|
|||
|
|
// 初始化录音管理器
|
|||
|
|
const initRecorderManager = () => {
|
|||
|
|
recorderManager = initRecorderManagerUtil(
|
|||
|
|
{},
|
|||
|
|
(res) => {
|
|||
|
|
// 若本次被标记为丢弃(取消录音触发的 stop),则忽略发送
|
|||
|
|
if (discardRecording) {
|
|||
|
|
discardRecording = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查录音时长
|
|||
|
|
if (
|
|||
|
|
!checkRecordingDuration(res, () => {
|
|||
|
|
isRecording.value = false;
|
|||
|
|
recordingText.value = "录音中...";
|
|||
|
|
})
|
|||
|
|
) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重置录音状态
|
|||
|
|
isRecording.value = false;
|
|||
|
|
recordingText.value = "录音中...";
|
|||
|
|
clearDurationTimer();
|
|||
|
|
|
|||
|
|
// 发送语音消息
|
|||
|
|
const duration = Math.floor(res.duration / 1000);
|
|||
|
|
sendVoiceMessage(res, duration);
|
|||
|
|
},
|
|||
|
|
(err) => {
|
|||
|
|
isRecording.value = false;
|
|||
|
|
recordingText.value = "录音中...";
|
|||
|
|
clearDurationTimer();
|
|||
|
|
discardRecording = false;
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 发送文本消息
|
|||
|
|
const sendTextMessage = async () => {
|
|||
|
|
if (!inputText.value.trim()) return;
|
|||
|
|
|
|||
|
|
await sendMessage("text", inputText.value);
|
|||
|
|
inputText.value = "";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 从常用语发送文本消息
|
|||
|
|
const sendTextMessageFromPhrase = async (content) => {
|
|||
|
|
if (!content.trim()) return;
|
|||
|
|
|
|||
|
|
await sendMessage("text", content);
|
|||
|
|
|
|||
|
|
// 发送成功后滚动到底部
|
|||
|
|
nextTick(() => {
|
|||
|
|
emit("scrollToBottom");
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 暴露方法给父组件调用
|
|||
|
|
defineExpose({
|
|||
|
|
sendTextMessageFromPhrase
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 发送图片消息
|
|||
|
|
const sendImageMessage = async (imageFile) => {
|
|||
|
|
console.log("chat-input sendImageMessage 被调用,参数:", imageFile);
|
|||
|
|
await sendMessage("image", imageFile);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 发送语音消息
|
|||
|
|
const sendVoiceMessage = async (voiceFile, duration) => {
|
|||
|
|
await sendMessage("voice", { file: voiceFile, duration });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 发送消息的通用方法(文本、图片、语音)
|
|||
|
|
const sendMessage = async (messageType, data) => {
|
|||
|
|
await sendMessageUtil(
|
|||
|
|
messageType,
|
|||
|
|
data,
|
|||
|
|
props.timChatManager,
|
|||
|
|
() => validateBeforeSend(false, false, props.timChatManager),
|
|||
|
|
() => {
|
|||
|
|
showMorePanel.value = false;
|
|||
|
|
// 发送成功后滚动到底部
|
|||
|
|
emit("messageSent");
|
|||
|
|
},
|
|||
|
|
cloudCustomData.value
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 发送自定义消息的通用方法
|
|||
|
|
const sendCustomMessage = async (messageData) => {
|
|||
|
|
await sendCustomMessageUtil(
|
|||
|
|
messageData,
|
|||
|
|
props.timChatManager,
|
|||
|
|
() => validateBeforeSend(false, false, props.timChatManager),
|
|||
|
|
() => {
|
|||
|
|
showMorePanel.value = false;
|
|||
|
|
emit("messageSent");
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 输入相关方法
|
|||
|
|
const toggleVoiceInput = () => {
|
|||
|
|
showVoiceInput.value = !showVoiceInput.value;
|
|||
|
|
showMorePanel.value = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const toggleMorePanel = () => {
|
|||
|
|
showMorePanel.value = !showMorePanel.value;
|
|||
|
|
showVoiceInput.value = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理图片选择
|
|||
|
|
const showImagePicker = () => {
|
|||
|
|
chooseImage(
|
|||
|
|
(file) => {
|
|||
|
|
console.log("选择图片成功,文件对象:", file);
|
|||
|
|
// 直接传递文件对象,不需要额外处理
|
|||
|
|
sendImageMessage(file);
|
|||
|
|
},
|
|||
|
|
(err) => {
|
|||
|
|
console.error("选择图片失败:", err);
|
|||
|
|
if (
|
|||
|
|
!err.errMsg?.includes("permission") &&
|
|||
|
|
!err.errMsg?.includes("auth") &&
|
|||
|
|
!err.errMsg?.includes("拒绝") &&
|
|||
|
|
!err.errMsg?.includes("未授权")
|
|||
|
|
) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: "选择图片失败,请重试",
|
|||
|
|
icon: "none",
|
|||
|
|
duration: 2000,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const takePhoto = () => {
|
|||
|
|
takePhotoUtil(
|
|||
|
|
(file) => {
|
|||
|
|
console.log("拍照成功,文件对象:", file);
|
|||
|
|
// 直接传递文件对象,不需要额外处理
|
|||
|
|
sendImageMessage(file);
|
|||
|
|
},
|
|||
|
|
(err) => {
|
|||
|
|
console.error("拍照失败:", err);
|
|||
|
|
if (
|
|||
|
|
!err.errMsg?.includes("permission") &&
|
|||
|
|
!err.errMsg?.includes("auth") &&
|
|||
|
|
!err.errMsg?.includes("拒绝") &&
|
|||
|
|
!err.errMsg?.includes("未授权")
|
|||
|
|
) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: "拍照失败,请重试",
|
|||
|
|
icon: "none",
|
|||
|
|
duration: 2000,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function startDurationTimer() {
|
|||
|
|
clearDurationTimer();
|
|||
|
|
recordingDuration.value = 0;
|
|||
|
|
recordingTimer = setInterval(() => {
|
|||
|
|
recordingDuration.value++;
|
|||
|
|
}, 1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDurationTimer() {
|
|||
|
|
if (recordingTimer) {
|
|||
|
|
clearInterval(recordingTimer);
|
|||
|
|
recordingTimer = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const startRecord = (e) => {
|
|||
|
|
isRecording.value = true;
|
|||
|
|
recordingText.value = "录音中...";
|
|||
|
|
isCancelMode.value = false;
|
|||
|
|
discardRecording = false;
|
|||
|
|
recordingDuration.value = 0;
|
|||
|
|
if (e && e.touches && e.touches[0]) {
|
|||
|
|
touchStartY = e.touches[0].clientY;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 确保录音管理器已初始化
|
|||
|
|
if (!recorderManager) {
|
|||
|
|
initRecorderManager();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
startDurationTimer();
|
|||
|
|
startRecordUtil(recorderManager);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onRecordTouchMove = (e) => {
|
|||
|
|
if (!isRecording.value) return;
|
|||
|
|
if (e && e.touches && e.touches[0]) {
|
|||
|
|
const currentY = e.touches[0].clientY;
|
|||
|
|
const deltaY = touchStartY - currentY; // 上滑为正
|
|||
|
|
isCancelMode.value = deltaY > CANCEL_DISTANCE;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const stopRecord = () => {
|
|||
|
|
if (!isRecording.value) return;
|
|||
|
|
// 如果处于取消模式,则按取消处理
|
|||
|
|
if (isCancelMode.value) {
|
|||
|
|
cancelRecord();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
isRecording.value = false;
|
|||
|
|
recordingText.value = "录音中...";
|
|||
|
|
clearDurationTimer();
|
|||
|
|
stopRecordUtil(recorderManager);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancelRecord = () => {
|
|||
|
|
if (!isRecording.value) return;
|
|||
|
|
isRecording.value = false;
|
|||
|
|
isCancelMode.value = false;
|
|||
|
|
recordingText.value = "已取消";
|
|||
|
|
discardRecording = true; // 标记为丢弃,阻止 onStop 发送
|
|||
|
|
clearDurationTimer();
|
|||
|
|
stopRecordUtil(recorderManager);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 发送问卷调查消息
|
|||
|
|
const sendSurveyMessage = async () => {
|
|||
|
|
const surveyMessage = createCustomMessage(
|
|||
|
|
"survey",
|
|||
|
|
{
|
|||
|
|
content: "医生发送了问卷调查",
|
|||
|
|
surveyTitle: "治疗效果评估",
|
|||
|
|
surveyDescription: "您好,为了帮助了解您的病情变化,请您如实填写问卷。",
|
|||
|
|
surveyMessage: "慢性病患者生活质量评估问卷",
|
|||
|
|
estimatedTime: "约3-5分钟",
|
|||
|
|
reward: "积分奖励10分",
|
|||
|
|
note: "问卷内容涉及您的症状变化、用药情况等,请根据实际情况填写。",
|
|||
|
|
},
|
|||
|
|
props.formatTime
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await sendCustomMessage(surveyMessage);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 跳转到常用语页面
|
|||
|
|
const goToCommonPhrases = () => {
|
|||
|
|
uni.navigateTo({
|
|||
|
|
url: '/pages/message/common-phrases'
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 跳转到宣教文章页面
|
|||
|
|
const goToArticleList = () => {
|
|||
|
|
uni.navigateTo({
|
|||
|
|
url: `/pages/message/article-list?groupId=${props.groupId}&userId=${props.userId}&corpId=${props.corpId}`
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 跳转到问卷列表页面
|
|||
|
|
const goToSurveyList = () => {
|
|||
|
|
uni.navigateTo({
|
|||
|
|
url: '/pages/message/survey-list'
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 结束问诊
|
|||
|
|
const handleEndConsult = () => {
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '确认结束问诊',
|
|||
|
|
content: '确定要结束本次问诊吗?结束后将无法继续对话。',
|
|||
|
|
confirmText: '确定结束',
|
|||
|
|
cancelText: '取消',
|
|||
|
|
success: (res) => {
|
|||
|
|
if (res.confirm) {
|
|||
|
|
// 关闭功能面板
|
|||
|
|
showMorePanel.value = false;
|
|||
|
|
// 触发父组件的结束问诊事件
|
|||
|
|
emit('endConsult');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const morePanelButtons = [
|
|||
|
|
{ text: "照片", icon: "/static/icon/zhaopian.png", action: showImagePicker },
|
|||
|
|
{
|
|||
|
|
text: "回访任务",
|
|||
|
|
icon: "/static/icon/zhaopian.png",
|
|||
|
|
action: showImagePicker,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
text: "常用语",
|
|||
|
|
icon: "/static/icon/changyongyu.png",
|
|||
|
|
action: goToCommonPhrases,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
text: "宣教",
|
|||
|
|
icon: "/static/icon/xuanjiaowenzhang.png",
|
|||
|
|
action: goToArticleList,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
text: "问卷",
|
|||
|
|
icon: "/static/icon/wenjuan.png",
|
|||
|
|
action: goToSurveyList,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
text: "结束问诊",
|
|||
|
|
icon: "/static/icon/jieshuzixun.png",
|
|||
|
|
action: handleEndConsult,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function handleInputFocus() {
|
|||
|
|
console.log("handleInputFocus");
|
|||
|
|
nextTick().then(() => {
|
|||
|
|
emit("scrollToBottom");
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 初始化录音管理器
|
|||
|
|
initRecorderManager();
|
|||
|
|
|
|||
|
|
// 监听关闭功能栏事件
|
|||
|
|
uni.$on("closeMorePanel", () => {
|
|||
|
|
showMorePanel.value = false;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
// 移除事件监听
|
|||
|
|
uni.$off("closeMorePanel");
|
|||
|
|
clearDurationTimer();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
@import "../chat.scss";
|
|||
|
|
</style>
|