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> |