2026-02-12 14:12:01 +08:00

537 lines
14 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.

<template>
<view class="input-section">
<!-- AI助手按钮组 -->
<view class="ai-assistant-slot">
<slot name="ai-assistant"></slot>
</view>
<view class="input-toolbar">
<view @click="toggleVoiceInput" class="voice-toggle-btn">
<image v-if="showVoiceInput" src="/static/jianpan.png" class="voice-toggle-icon" mode="aspectFit"></image>
<uni-icons v-else type="mic" size="28" color="#666" />
</view>
<view class="input-area">
<textarea v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
@confirm="sendTextMessage" @focus="handleInputFocus" @input="handleInput"
:auto-height="true" :show-confirm-bar="false" :hold-keyboard="true"
ref="textareaRef"
/>
<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() && !props.isGenerating" class="send-btn" @click="sendTextMessage">
发送
</button>
<view v-else-if="!inputText.trim() && !props.isGenerating" class="plus-btn" @click="toggleMorePanel()">
<uni-icons type="plusempty" size="28" color="#666" />
</view>
<view v-else class="send-btn disabled-btn">
<text>生成中...</text>
</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: "" },
teamId: { type: String, default: "" },
patientId: { type: String, default: "" },
corpId: { type: String, default: "" },
orderStatus: { type: String, default: "" },
isGenerating: { type: Boolean, default: false },
});
// Emits
const emit = defineEmits([
"messageSent",
"scrollToBottom",
"endConsult",
"openConsult",
]);
// 输入相关状态
const inputText = ref("");
const showVoiceInput = ref(false);
const showMorePanel = ref(false);
const isRecording = ref(false);
const recordingText = ref("录音中...");
const textareaRef = ref(null);
const cloudCustomData = computed(() => {
const arr = [
props.chatRoomBusiness.businessType,
props.chatRoomBusiness.businessId,
];
return arr.filter(Boolean).join("|");
});
// 流式输入文本
const appendStreamText = (char) => {
inputText.value += char;
};
// 录音相关扩展状态(特效 + 取消逻辑)
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;
const textToSend = inputText.value;
inputText.value = "";
await sendMessage("text", textToSend);
// 发送后保持焦点,不收起键盘
nextTick(() => {
// 通过设置 focus 属性来保持键盘显示
// 注意:在某些情况下可能需要延迟执行
setTimeout(() => {
// 这里不需要手动聚焦,因为 hold-keyboard 会保持键盘
}, 50);
});
};
// 从常用语发送文本消息
const sendTextMessageFromPhrase = async (content) => {
if (!content.trim()) return;
await sendMessage("text", content);
// 发送成功后滚动到底部
nextTick(() => {
emit("scrollToBottom");
});
};
// 设置输入框文本(覆盖原内容)
const setInputText = (text) => {
inputText.value = text;
};
// 清空输入框
const clearInputText = () => {
inputText.value = "";
};
// 暴露方法给父组件调用
defineExpose({
sendTextMessageFromPhrase,
appendStreamText,
setInputText,
clearInputText,
});
// 发送图片消息
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 goToCommonPhrases = () => {
uni.navigateTo({
url: "/pages/message/common-phrases",
});
};
// 打开回访任务列表
const showFollowUpTasks = () => {
uni.navigateTo({
url: `/pages/case/followup-task-list?archiveId=${props.patientId}&patientName=${props.patientInfo.name}`,
});
};
// 跳转到宣教文章页面
const goToArticleList = () => {
uni.navigateTo({
url: `/pages/message/article-list?groupId=${props.groupId}&patientId=${props.patientId}&corpId=${props.corpId}&teamId=${props.teamId}`,
});
};
// 跳转到问卷列表页面
const goToSurveyList = () => {
uni.navigateTo({
url: `/pages/message/survey-list?groupId=${props.groupId}&patientId=${props.patientId}&corpId=${props.corpId}&teamId=${props.teamId}&customerName=${props.patientInfo.name}`,
});
};
// 结束问诊
const handleEndConsult = () => {
uni.showModal({
title: "确认结束问诊",
content: "确定要结束本次问诊吗?结束后将无法继续对话。",
confirmText: "确定结束",
cancelText: "取消",
success: (res) => {
if (res.confirm) {
// 关闭功能面板
showMorePanel.value = false;
// 触发父组件的结束问诊事件
emit("endConsult");
}
},
});
};
// 开启会话
const handleOpenConsult = () => {
uni.showModal({
title: "确认开启会话",
content: "确定要重新开启本次会话吗?",
confirmText: "确定开启",
cancelText: "取消",
success: (res) => {
if (res.confirm) {
// 关闭功能面板
showMorePanel.value = false;
// 触发父组件的开启会话事件
emit("openConsult");
}
},
});
};
const morePanelButtons = computed(() => {
const buttons = [
{
text: "照片",
icon: "/static/icon/zhaopian.png",
action: showImagePicker,
},
{
text: "回访任务",
icon: "/static/icon/huifangrenwu.png",
action: showFollowUpTasks,
},
{
text: "常用语",
icon: "/static/icon/changyongyu.png",
action: goToCommonPhrases,
},
{
text: "宣教",
icon: "/static/icon/xuanjiaowenzhang.png",
action: goToArticleList,
},
{
text: "问卷",
icon: "/static/icon/wenjuan.png",
action: goToSurveyList,
},
];
// 根据订单状态显示不同的按钮
if (props.orderStatus === "finished") {
// 已结束状态:显示"开启会话"按钮
buttons.push({
text: "开启会话",
icon: "/static/icon/openChat.png",
action: handleOpenConsult,
});
} else {
// 处理中状态:显示"结束问诊"按钮
buttons.push({
text: "结束问诊",
icon: "/static/icon/jieshuzixun.png",
action: handleEndConsult,
});
}
return buttons;
});
function handleInputFocus() {
console.log("handleInputFocus");
nextTick().then(() => {
emit("scrollToBottom");
});
}
function handleInput(e) {
// textarea 输入时触发,可以在这里处理额外逻辑
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>