Files
xk-client-wx/subPackages/chat/chat.vue
2026-07-07 08:50:17 +08:00

1325 lines
46 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="chat-container">
<!-- ================= 自定义导航栏 ================= -->
<u-navbar
class="chat-navbar"
:is-back="true"
:title="doctorUserInfo.nick_name || '在线问诊'"
:custom-back="handleBack"
title-color="#333"
:border-bottom="false"
></u-navbar>
<!-- ================= 顶部导航栏 (保持原样) ================= -->
<view class="chat-header">
<view class="header-main" @click="toggleDoctorDetail">
<view class="doctor-info-group">
<text class="doctor-name">{{ doctorUserInfo.nick_name }}</text>
<view class="doctor-badges">
<text class="status-dot"></text>
<text class="sub-text">在线</text>
</view>
</view>
<view class="header-action">
<text class="expand-tip">{{ showDoctorDetail ? '收起' : '详情' }}</text>
<u-icon :name="showDoctorDetail ? 'arrow-up' : 'arrow-down'" size="12" color="#0A84FF"></u-icon>
</view>
</view>
<view class="doctor-detail-panel" :class="{ 'panel-show': showDoctorDetail }">
<view class="detail-grid">
<view class="detail-row">
<text class="label">职称</text>
<text class="value tag-value">{{ doctorDetailInfo.title || '--' }}</text>
</view>
<view class="detail-row">
<text class="label">医院</text>
<text class="value">{{ doctorDetailInfo.hospital || '--' }}</text>
</view>
<view class="detail-row full-width">
<text class="label">擅长</text>
<text class="value desc-value">{{ doctorDetailInfo.good_at || '--' }}</text>
</view>
</view>
</view>
</view>
<!-- ================= 聊天消息滚动区域 ================= -->
<scroll-view
class="chat-messages"
:style="scrollViewStyle"
scroll-y="true"
:scroll-into-view="scrollIntoView"
:scroll-top="scrollTop"
scroll-with-animation
@scrolltoupper="loadHistory"
@scroll="onScroll"
>
<view class="message-list-container" id="msglist" :style="{ paddingBottom: (keyboardHeight > 0 ? keyboardHeight + 10 : 0) + 'px' }">
<!-- 协议提示信息 -->
<view class="notice-box" v-if="showNotice">
<view class="notice-text">
<text v-for="(part, index) in parsedNoticeParts" :key="index">
<text v-if="part.type === 'text'">{{ part.text }}</text>
<text v-else-if="part.type === 'agreement'" class="notice-link" @click="viewAgreement(part.agreementId)">
{{ part.text }}
</text>
</text>
</view>
</view>
<!-- 加载指示器 -->
<view v-if="loading" class="loading-spinner">
<view class="spinner"></view>
<text class="spinner-text">历史记录加载中...</text>
</view>
<!-- 无更多消息 -->
<view v-if="isEmpty" class="empty-state">
<text class="empty-text">没有更多消息了</text>
</view>
<!-- 消息列表循环 -->
<view v-for="(msg, index) in messages" :key="index" class="message-wrapper">
<!-- 时间 -->
<view class="time-divider" v-if="shouldShowTime(msg, index)">
<text class="time-text">{{ msg.created_at_text || msg.time }}</text>
</view>
<!-- 使用抽离的组件 -->
<MessageBubble
:msg="msg"
:is-mine="isMsgMine(msg)"
:avatar="getMsgAvatar(msg)"
:is-playing="isPlaying(msg)"
@previewImage="previewImage"
@playAudio="playAudio"
@viewRegister="viewRegister"
@viewPrescription="viewPrescription"
@goToOrderMedicine="goToOrderMedicine"
@viewOrder="viewOrder"
@followUpDrugAnswered="onFollowUpDrugAnswered"
/>
</view>
<!-- 底部锚点 -->
<view id="chat-bottom-anchor" style="height: 1px; width: 100%;"></view>
</view>
<!-- 底部静态占位 -->
<view style="height: 180rpx;"></view>
</scroll-view>
<!-- 协议抽屉 -->
<page-container
:if="showAgreement"
position="bottom"
round
overlay
custom-style="height:88%;"
@clickoverlay="closeAgreement"
@afterleave="closeAgreement"
>
<view class="agreement-popup">
<view class="agreement-header">
<text class="agreement-title">{{ currentAgreementName || '协议详情' }}</text>
<view class="agreement-close" @click="closeAgreement">×</view>
</view>
<scroll-view scroll-y class="agreement-content" :show-scrollbar="true">
<u-parse :html="agreementContent"></u-parse>
</scroll-view>
</view>
</page-container>
<!-- ================= 底部输入与工具栏 (重构优化版) ================= -->
<view class="chat-footer safe-area-inset-bottom" :style="{ bottom: keyboardHeight + 'px' }">
<view v-if="isConsultationEnded" class="consultation-ended-notice">
<u-icon name="checkmark-circle-fill" size="18" color="#999"></u-icon>
<text>本次问诊未开始或已结束</text>
</view>
<template v-else>
<!-- 相册工具栏 -->
<view class="toolbar-area" v-if="false">
<!-- 暂时隐藏 -->
</view>
<view class="input-bar">
<!-- 左侧语音切换按钮 -->
<view class="icon-btn left" @click="toggleVoiceInput">
<u-icon :name="voiceInputActive ? 'close' : 'mic'" size="26" color="#333"></u-icon>
</view>
<!-- 中间输入框 / 按住说话 -->
<view class="input-field-wrapper">
<input
v-if="!voiceInputActive"
v-model="newMessage"
class="main-input"
:adjust-position="false"
confirm-type="send"
@confirm="sendTextMessage"
cursor-spacing="20"
/>
<view v-else class="voice-btn"
:class="{ 'recording': recording }"
@touchstart="startVoiceRecording"
@touchend="stopVoiceRecording"
@touchcancel="stopVoiceRecording">
<text>{{ recording ? '松开 发送' : '按住 说话' }}</text>
</view>
</view>
<!-- 右侧发送按钮 加号 -->
<view class="right-action">
<view v-if="newMessage.trim()" class="send-btn" @click="sendTextMessage">发送</view>
<view v-else class="icon-btn" @click="openMediaPicker('image')">
<u-icon name="plus-circle" size="26" color="#333"></u-icon>
</view>
</view>
</view>
</template>
</view>
<!-- 录音蒙层 (保持原样) -->
<view v-if="showRecordingModal" class="recording-overlay">
<view class="recording-box">
<view class="wave-animation"></view>
<view style="margin: 20rpx 0;">
<u-icon name="mic-fill" size="40" color="#fff"></u-icon>
</view>
<text class="timer">正在录音 {{ recordingTime }}s</text>
<text class="tip">手指上滑取消</text>
</view>
</view>
</view>
</template>
<script>
import MessageBubble from './components/MessageBubble.vue'; // 引入组件
import { webSocketManager } from '@/utils/ws/websocket';
import { ChatManager } from '@/store/chat/chat.js';
import {getChatRegisterInfoApi, sendToUserApi, upLoadChatFileApi, getDoctorInfoApi, getRoomStatusApi} from "@/request/api/im";
import {getTransferAssistantConfigApi, getTransferConsultationAgreementInfoApi, getAgreementDetailApi} from "@/request/api/transferPrescription";
import { checkDev } from '@/utils/utils';
import { prepareImagePath } from '@/utils/image-compress.js';
// 根据环境获取上传URL
const isDev = checkDev('dev');
const uploadBaseUrl = isDev ? 'http://127.0.0.1:18001/api/mobile' : 'https://api.xiaokang88.com/api/mobile';
// 辅助函数保留在父组件逻辑中
function getParsedContent(messageType, messageContent) {
if ([4, 9, 10, 11, 12, 13].includes(messageType)) {
try {
return typeof messageContent === 'object' ? messageContent : JSON.parse(messageContent);
} catch (e) {
return messageContent;
}
}
return messageContent;
}
export default {
components: {
MessageBubble
},
data() {
return {
doctorUserInfo: { id: 0, avatar: '', nick_name: '医生', room_id: '' },
doctorDetailInfo: { title: '', hospital: '', good_at: '' }, // 医生详情信息
showDoctorDetail: false,
currentUserId: '',
doctorId: 'doctor-1',
doctorAvatar: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg',
userAvatar: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg',
assistantConfig: { name: '医生助理', avatar: '' }, // 助理配置信息
messages: [],
newMessage: '',
voiceInputActive: false, // 默认文字输入
recording: false,
recordingTime: 0,
recordingTimer: null,
showRecordingModal: false,
currentPlayingAudio: null,
innerAudioContext: null, // 音频播放上下文
hasRecordPermission: false,
scrollTop: 0,
scrollIntoView: '',
scrollViewHeight: 0,
oldScrollViewHeight: 0,
scrollViewStyle: '',
currentScrollPosition: 0,
page: 1,
pageSize: 20,
hasMore: true,
loading: false,
isEmpty: false,
isLoadHistory: false,
isConsultationEnded: false,
keyboardHeight: 0,
showNotice: true, // 显示协议提示
noticeText: '', // 提示文案
agreements: [], // 协议列表
parsedNoticeParts: [], // 解析后的文案部分(文本和协议链接)
showAgreement: false, // 控制协议抽屉显示
agreementContent: '', // 协议内容
currentAgreementId: 0, // 当前查看的协议ID
currentAgreementName: '', // 当前查看的协议名称
};
},
onLoad: function (option) {
// 确保 room_id 存在
if (!option.room_id) {
console.error('聊天页面缺少room_id参数');
uni.showToast({
title: '房间ID不能为空',
icon: 'none'
});
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
});
}, 1500);
return;
}
// 先设置基础信息
this.doctorUserInfo = option;
if (option.avatar) this.doctorAvatar = option.avatar;
this.currentUserId = 'user-' + uni.getStorageSync('user_id') || 'user-2512';
let doctorId = option.user_id || '';
if (doctorId.includes('-type-')) doctorId = doctorId.split('-type-')[0];
this.doctorId = doctorId;
// 立即进入房间并设置ChatManager确保消息能正确路由
if (typeof ChatManager !== 'undefined') {
ChatManager.enterRoom(option.room_id);
ChatManager.resetUnreadCount(option.room_id);
this.messages = ChatManager.getRoomMessages(option.room_id);
if (option.room_status === '1' || option.room_status === 1) this.isConsultationEnded = true;
else if (this.messages.some(msg => msg.message_type === 13)) this.isConsultationEnded = true;
}
if (option.room_id) this.fetchRoomStatus(option.room_id);
// 无论是否有nickName都需要获取医生详情信息职称、医院、擅长
this.fetchDoctorInfo(doctorId);
// 加载助理配置信息和协议信息
this.loadAssistantConfig();
this.loadAgreementInfo();
const nickName = this.doctorUserInfo.nick_name;
if (nickName && nickName !== 'undefined' && nickName !== 'null') {
uni.setNavigationBarTitle({ title: `${nickName}医生的对话` });
} else {
uni.setNavigationBarTitle({ title: '与医生的对话' });
}
this.$nextTick(() => {
this.scrollToBottom();
});
uni.onKeyboardHeightChange(res => {
this.keyboardHeight = res.height;
if (res.height > 0) this.scrollToBottom();
});
},
onBackPress() {
if (this.showAgreement) {
this.closeAgreement();
return true;
}
this.handleBack();
return true;
},
onUnload() {
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
}
if (typeof ChatManager !== 'undefined') ChatManager.leaveRoom();
},
onShow() {
// 确保房间ID已设置从ChatManager获取作为备用
if (!this.doctorUserInfo.room_id && typeof ChatManager !== 'undefined') {
const currentRoomId = ChatManager.getCurrentRoomId();
if (currentRoomId) {
this.doctorUserInfo.room_id = currentRoomId;
}
}
// 注册事件监听器
uni.$on('new-room-message', this.handleNewRoomMessage);
uni.$on('load-room-message', this.handLoadMessageList);
uni.$on('no-more-history', this.handNoMessageList);
},
onHide() {
this.stopAudio(); // 页面隐藏时停止播放
uni.$off('new-room-message', this.handleNewRoomMessage);
uni.$off('load-room-message', this.handLoadMessageList);
uni.$off('no-more-history', this.handNoMessageList);
},
mounted() {
this.recorderManager = uni.getRecorderManager();
this.initRecorder();
// 延迟注册WebSocket消息处理器确保onLoad已执行完成room_id已设置
this.$nextTick(() => {
if (typeof webSocketManager !== 'undefined') {
webSocketManager.addMessageHandler(this.handleSocketMessage);
}
});
this.calculateScrollViewHeight();
},
beforeDestroy() {
if (typeof webSocketManager !== 'undefined') webSocketManager.removeMessageHandler(this.handleSocketMessage);
},
methods: {
// === 新增辅助方法 ===
isMsgMine(msg) {
return this.getSenderIdWithoutPrefix(msg.sender_user_id) === this.getCurrentUserIdWithoutPrefix();
},
getMsgAvatar(msg) {
if (this.isMsgMine(msg)) {
return this.userAvatar;
}
// 如果是医生助理发送的消息
const senderId = msg.sender_user_id || msg.senderId;
if (senderId === 'doctor_assistant') {
const avatar = this.assistantConfig.avatar || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
try {
return decodeURIComponent(avatar);
} catch (e) {
return avatar;
}
}
// 其他情况(医生发送的消息)
let avatar = this.doctorUserInfo.avatar || this.doctorAvatar || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
try {
return decodeURIComponent(avatar);
} catch (e) {
return avatar;
}
},
onFollowUpDrugAnswered({ messageId, answerStatus }) {
const roomId = this.doctorUserInfo.room_id;
const idx = this.messages.findIndex((m) => m.id === messageId);
if (idx === -1) return;
const row = this.messages[idx];
try {
const o = typeof row.message_content === 'string' ? JSON.parse(row.message_content) : { ...row.message_content };
o.answer_status = answerStatus;
const updated = { ...row, message_content: JSON.stringify(o) };
this.$set(this.messages, idx, updated);
if (typeof ChatManager !== 'undefined' && roomId && ChatManager.roomMessages && ChatManager.roomMessages[roomId]) {
const ri = ChatManager.roomMessages[roomId].findIndex((m) => m.id === messageId);
if (ri !== -1) this.$set(ChatManager.roomMessages[roomId], ri, updated);
}
} catch (e) {
console.error('onFollowUpDrugAnswered', e);
}
},
// 加载助理配置信息
async loadAssistantConfig() {
try {
const res = await getTransferAssistantConfigApi();
if (res.data && res.data.code === 0) {
const config = res.data.result || {};
this.assistantConfig = {
name: config.name || '医生助理',
avatar: config.avatar || ''
};
}
} catch (error) {
console.error('加载助理配置失败:', error);
// 失败不影响继续,使用默认值
}
},
// 加载协议信息
async loadAgreementInfo() {
try {
const res = await getTransferConsultationAgreementInfoApi();
if (res.data && res.data.code === 0) {
const result = res.data.result || {};
this.noticeText = result.notice_text || '';
this.agreements = result.agreements || [];
// 解析文案,提取协议链接
this.parseNoticeText();
}
} catch (error) {
console.error('加载协议信息失败:', error);
// 失败时使用默认文案
this.noticeText = '根据国家互联网医院管理办法要求,平台仅为复诊患者提供服务。为了保障您的用药安全,购买该药物需要经过医生问诊,请根据真实情况回答,并请仔细阅读《互联网医疗风险告知及知情同意书》,继续咨询即表示您已知悉相关规则与风险并同意相关条款。';
this.parseNoticeText();
}
},
// 解析提示文案,将《协议名称》替换为可点击的链接
parseNoticeText() {
if (!this.noticeText) {
this.parsedNoticeParts = [];
return;
}
const parts = [];
// 使用正则表达式匹配《》中的协议名称
const regex = /《([^》]+)》/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(this.noticeText)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
parts.push({
type: 'text',
text: this.noticeText.substring(lastIndex, match.index)
});
}
// 查找匹配的协议
const agreementName = match[1];
const agreement = this.agreements.find(ag => ag.name === agreementName || ag.name.includes(agreementName));
if (agreement) {
// 找到匹配的协议,添加为可点击的链接
parts.push({
type: 'agreement',
text: agreementName,
agreementId: agreement.id
});
} else {
// 没找到匹配的协议,作为普通文本显示
parts.push({
type: 'text',
text: match[0] // 《协议名称》
});
}
lastIndex = regex.lastIndex;
}
// 添加剩余的文本
if (lastIndex < this.noticeText.length) {
parts.push({
type: 'text',
text: this.noticeText.substring(lastIndex)
});
}
this.parsedNoticeParts = parts;
},
// 查看协议
async viewAgreement(agreementId) {
if (!agreementId) {
uni.showToast({
title: '协议ID不能为空',
icon: 'none'
});
return;
}
// 查找协议名称
const agreement = this.agreements.find(ag => ag.id === agreementId);
this.currentAgreementId = agreementId;
this.currentAgreementName = agreement ? agreement.name : '协议详情';
this.showAgreement = true;
try {
const res = await getAgreementDetailApi(agreementId);
if (res.data && res.data.code === 0) {
const result = res.data.result || {};
this.agreementContent = result.content || '';
// 如果API返回了协议名称使用API返回的名称
if (result.name) {
this.currentAgreementName = result.name;
}
} else {
throw new Error(res.data.message || '获取协议详情失败');
}
} catch (error) {
console.error('获取协议详情失败:', error);
uni.showToast({
title: error.message || '获取协议详情失败',
icon: 'none'
});
this.showAgreement = false;
}
},
closeAgreement() {
this.showAgreement = false;
},
// === 音频播放逻辑 ===
playAudio(msg) {
const audioUrl = getParsedContent(msg.message_type, msg.message_content);
// 如果点击的是当前正在播放的,则暂停/停止
if (this.currentPlayingAudio === msg.id) {
this.stopAudio();
return;
}
// 停止当前正在播放的
this.stopAudio();
if (!audioUrl) {
uni.showToast({ title: '无效的音频地址', icon: 'none' });
return;
}
// 初始化音频上下文
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
// 监听自然播放结束
this.innerAudioContext.onEnded(() => {
this.currentPlayingAudio = null;
});
// 监听播放错误
this.innerAudioContext.onError((res) => {
console.error('音频播放错误', res);
this.currentPlayingAudio = null;
uni.showToast({ title: '播放失败', icon: 'none' });
});
}
this.innerAudioContext.src = audioUrl;
this.innerAudioContext.play();
this.currentPlayingAudio = msg.id;
},
stopAudio() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
}
this.currentPlayingAudio = null;
},
isPlaying(msg) {
return this.currentPlayingAudio === msg.id;
},
// ... 所有的业务逻辑方法保持原样,无需改动 ...
async fetchDoctorInfo(doctorId) {
try {
const cleanDoctorId = String(doctorId).replace('doctor-', '');
const res = await getDoctorInfoApi({ doctor_id: cleanDoctorId });
if (res.data && res.data.result) {
const doctorInfo = res.data.result;
this.doctorUserInfo = {
...this.doctorUserInfo,
nick_name: doctorInfo.nick_name || '医生',
avatar: doctorInfo.avatar || this.doctorAvatar,
};
// 保存完整的医生详情信息
this.doctorDetailInfo = {
title: doctorInfo.title || '',
hospital: doctorInfo.hospital || '',
good_at: doctorInfo.good_at || ''
};
if (doctorInfo.avatar) this.doctorAvatar = doctorInfo.avatar;
uni.setNavigationBarTitle({ title: `${this.doctorUserInfo.nick_name}医生的对话` });
}
} catch (error) { console.error('获取医生信息失败:', error); }
},
async fetchRoomStatus(roomId) {
try {
const res = await getRoomStatusApi({ room_id: roomId });
if (res.data && res.data.result) this.isConsultationEnded = res.data.result.status === 1;
} catch (error) { console.error('获取房间状态失败:', error); }
},
checkPrivacyAuthorize() {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
if (typeof uni.requirePrivacyAuthorize === 'function') {
uni.requirePrivacyAuthorize({
success: () => resolve(),
fail: () => {
uni.showToast({ title: '需要同意隐私协议才能使用此功能', icon: 'none' });
reject(new Error('隐私授权被拒绝'));
}
});
} else { resolve(); }
// #endif
// #ifndef MP-WEIXIN
resolve();
// #endif
});
},
calculateScrollViewHeight() {
this.$nextTick(() => {
const systemInfo = uni.getSystemInfoSync();
const windowHeight = systemInfo.windowHeight;
const query = uni.createSelectorQuery().in(this);
// 获取u-navbar导航栏高度uView会自动处理状态栏高度
query.select('.chat-navbar').boundingClientRect((navbarRect) => {
const navbarHeight = navbarRect ? navbarRect.height : 0;
// 获取聊天头部高度
query.select('.chat-header').boundingClientRect((headerRect) => {
const headerHeight = headerRect ? headerRect.height : 0;
// 获取底部输入栏高度
const query2 = uni.createSelectorQuery().in(this);
query2.select('.chat-footer').boundingClientRect((footerRect) => {
const footerHeight = footerRect ? footerRect.height : 0;
const scrollHeight = windowHeight - navbarHeight - headerHeight - footerHeight;
this.scrollViewStyle = `height: ${scrollHeight}px;`;
}).exec();
}).exec();
}).exec();
});
},
onScroll(e) { this.currentScrollPosition = e.detail.scrollTop; },
getSenderIdWithoutPrefix(senderId) { return !senderId ? '' : String(senderId).replace(/^(user-|doctor-)/, ''); },
getCurrentUserIdWithoutPrefix() { return !this.currentUserId ? '' : String(this.currentUserId).replace(/^(user-|doctor-)/, ''); },
shouldShowTime(msg, index) {
if (index === 0) return true;
const prevMsg = this.messages[index - 1];
const currTime = msg.created_at || msg.timestamp || 0;
const prevTime = prevMsg.created_at || prevMsg.timestamp || 0;
return currTime - prevTime > 300000;
},
loadRegisterCards() {
let that = this;
this.messages.forEach((msg, index) => {
if (msg.message_type === 10) {
const parsedContent = getParsedContent(msg.message_type, msg.message_content);
if (parsedContent._loaded || !parsedContent.id) return;
that.fetchRegisterCardInfo(msg, index, parsedContent.id);
}
});
},
fetchRegisterCardInfo(msg, index, registerId) {
let that = this;
const messageId = msg.id;
getChatRegisterInfoApi({ id: registerId, message_id: messageId || '' }).then((res) => {
if (res.data && res.data.result) {
let targetIndex = index;
if (messageId) {
const foundIndex = that.messages.findIndex(m => m.id === messageId || String(m.id) === String(messageId));
if (foundIndex !== -1) targetIndex = foundIndex;
}
const resultWithFlag = { ...res.data.result, _loaded: true };
that.$set(that.messages, targetIndex, {
...that.messages[targetIndex],
message_content: JSON.stringify(resultWithFlag)
});
}
});
},
handNoMessageList() { this.isEmpty = true; this.hasMore = false; },
handLoadMessageList(type = 0) {
this.loading = false;
if (typeof ChatManager !== 'undefined') this.messages = ChatManager.roomMessages[this.doctorUserInfo.room_id] || [];
this.loadRegisterCards();
const isHistoryLoad = this.isLoadHistory;
this.isLoadHistory = false;
if (type === 1 && !isHistoryLoad) this.scrollToBottom();
else this.scrollToOldLastMessage();
},
getScrollHeight() {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list-container').boundingClientRect(res => {
if (res) {
this.oldScrollViewHeight = res.height;
this.scrollViewHeight = res.height;
}
}).exec();
},
handleSocketMessage(data) {
// 获取当前房间ID优先使用doctorUserInfo.room_id如果未设置则从ChatManager获取
const currentRoomId = this.doctorUserInfo.room_id || (typeof ChatManager !== 'undefined' ? ChatManager.getCurrentRoomId() : null);
// 如果房间ID匹配或者消息的房间ID与当前房间ID匹配则处理消息
if (data.room_id && (data.room_id === currentRoomId || data.room_id === this.doctorUserInfo.room_id)) {
this.addMessage(data);
this.scrollToBottom();
}
},
handleNewRoomMessage(message) {
this.addMessage(message);
this.scrollToBottom();
},
addMessage(message) {
if (!message.message_content && message.content) message.message_content = message.content;
if (message.message_type === undefined && message.type !== undefined) {
const messageTypeMap = { text: 0, image: 1, audio: 2, video: 3, prescription: 4, file: 5, register: 10, 'patient-experience': 11, 'product-card': 12, 'end-consultation': 13 };
message.message_type = messageTypeMap[message.type] || 0;
}
const existingIndexById = this.messages.findIndex(m => m.id === message.id);
if (existingIndexById !== -1) {
this.messages[existingIndexById] = message;
return;
}
const normalizeContent = (content) => {
if (!content) return '';
if (typeof content === 'string') {
try { return JSON.stringify(JSON.parse(content)); } catch (e) { return content; }
} else if (typeof content === 'object') { return JSON.stringify(content); }
return String(content);
};
if (!message.isTemporary) {
const tempMessageIndex = this.messages.findIndex(m => {
if (!m.isTemporary) return false;
if ((m.sender_user_id || '') !== (message.sender_user_id || '')) return false;
if (normalizeContent(m.message_content || m.content || '') !== normalizeContent(message.message_content || message.content || '')) return false;
return Math.abs((m.created_at || m.timestamp || 0) - (message.created_at || message.timestamp || 0)) < 30000;
});
if (tempMessageIndex !== -1) {
this.messages[tempMessageIndex] = message;
return;
}
}
this.messages.push(message);
if (message.message_type === 10) {
const parsedContent = getParsedContent(message.message_type, message.message_content);
if (!parsedContent._loaded && parsedContent.id) this.fetchRegisterCardInfo(message, this.messages.length - 1, parsedContent.id);
}
if (message.message_type === 13) this.isConsultationEnded = true;
if (this.doctorUserInfo.room_id) this.fetchRoomStatus(this.doctorUserInfo.room_id);
},
toggleDoctorDetail() { this.showDoctorDetail = !this.showDoctorDetail; },
initRecorder() {
this.recorderManager.onStop(res => {
if (res.duration < 1000) {
uni.showToast({ title: '录音时间太短', icon: 'none' });
this.recording = false;
this.showRecordingModal = false;
clearInterval(this.recordingTimer);
return;
}
this.uploadAudioFile(res.tempFilePath, Math.floor(res.duration / 1000));
this.recording = false;
this.showRecordingModal = false;
clearInterval(this.recordingTimer);
});
this.recorderManager.onError((error) => {
console.error('录音错误:', error);
this.recording = false;
this.showRecordingModal = false;
clearInterval(this.recordingTimer);
});
},
async uploadAudioFile(tempFilePath, duration) {
uni.showLoading({ title: '上传中...', mask: true });
try {
uni.uploadFile({
url: `${uploadBaseUrl}/chat-friends/upload-chat-file`,
filePath: tempFilePath,
name: 'file',
header: { "authorization": uni.getStorageSync('token') },
formData: {},
success: (uploadFileRes) => {
this.sendMessage({
type: 'audio',
content: JSON.parse(uploadFileRes.data).result.url,
duration,
});
}
});
} catch (error) { console.error('音频上传失败:', error); } finally { uni.hideLoading(); }
},
sendMessage(message) {
const now = new Date();
const timeStr = this.formatTime(now);
let receiverUserId = this.doctorId || '';
if (receiverUserId.includes('-type-')) receiverUserId = receiverUserId.split('-type-')[0];
if (receiverUserId && !receiverUserId.startsWith('doctor-')) receiverUserId = 'doctor-' + receiverUserId;
const messageTypeMap = { text: 0, image: 1, audio: 2, video: 3, prescription: 4, file: 5, register: 10, 'patient-experience': 11, 'product-card': 12, 'end-consultation': 13 };
const messageType = messageTypeMap[message.type] || 0;
const fullMessage = {
...message,
id: Date.now().toString(),
sender_user_id: this.currentUserId,
receiver_user_id: receiverUserId,
room_id: this.doctorUserInfo.room_id,
created_at_text: timeStr,
created_at: now.getTime(),
timestamp: now.getTime(),
message_content: message.message_content || message.content || '',
message_type: messageType,
isTemporary: true
};
this.addMessage(fullMessage);
this.scrollToBottom();
let senderUserId = `user-${this.currentUserId}`;
if (senderUserId.startsWith('user-user-')) {
senderUserId = senderUserId.replace(/^user-user-/, 'user-');
}
const requestData = {
room_id: this.doctorUserInfo.room_id,
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
message_type: messageType,
message_content: message.message_content || message.content || '',
isFromHttp: true,
duration: message.duration || 0
};
if (typeof sendToUserApi !== 'undefined') sendToUserApi(requestData);
},
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
},
sendTextMessage() {
if (!this.newMessage.trim()) return;
this.sendMessage({ type: 'text', content: this.newMessage });
this.newMessage = '';
},
async startVoiceRecording() {
if (this.recording) return;
try { await this.checkPrivacyAuthorize(); } catch (e) { return; }
uni.getSetting({
success: (res) => {
if (res.authSetting['scope.record']) {
this.hasRecordPermission = true;
this.startRecording();
} else {
uni.authorize({
scope: 'scope.record',
success: () => {
this.hasRecordPermission = true;
this.startRecording();
},
fail: () => {
this.hasRecordPermission = false;
this.voiceInputActive = false;
}
});
}
}
});
},
startRecording() {
this.recording = true;
this.showRecordingModal = true;
this.recordingTime = 0;
this.recorderManager.start({ format: 'mp3', sampleRate: 44100, numberOfChannels: 1, encodeBitRate: 192000 });
this.recordingTimer = setInterval(() => {
this.recordingTime++;
if (this.recordingTime >= 60) this.stopVoiceRecording();
}, 1000);
},
stopVoiceRecording() {
if (!this.recording) return;
this.recorderManager.stop();
},
async openMediaPicker(type) {
try { await this.checkPrivacyAuthorize(); } catch (e) { return; }
if (type === 'image') {
uni.chooseImage({
count: 9, sourceType: ['album', 'camera'],
success: res => { res.tempFilePaths.forEach(file => { this.uploadAndSendFile(file, 'image'); }); }
});
}
// if (type === 'image') {
// uni.chooseImage({
// count: 9,
// sourceType: ['album', 'camera'],
// success: async (res) => {
// for (let index = 0; index < res.tempFiles.length; index++) {
// const tempFile = res.tempFiles[index];
// const prepared = await prepareImagePath({
// path: tempFile.path,
// size: tempFile.size,
// });
// if (!prepared) continue;
// this.uploadAndSendFile(prepared.path, 'image');
// }
// },
// });
// }
},
uploadAndSendFile(filePath, type) {
uni.showLoading({ title: '上传中...', mask: true });
uni.uploadFile({
url: `${uploadBaseUrl}/chat-friends/upload-chat-file`,
filePath: filePath,
name: 'file',
header: { "authorization": uni.getStorageSync('token') },
formData: {},
success: (uploadFileRes) => {
uni.hideLoading();
try {
const result = JSON.parse(uploadFileRes.data);
if (result.code === 0 && result.result && result.result.url) {
this.sendMessage({ type: type, content: result.result.url });
} else { uni.showToast({ title: '上传失败', icon: 'none' }); }
} catch (e) { uni.showToast({ title: '上传失败', icon: 'none' }); }
},
fail: () => { uni.hideLoading(); uni.showToast({ title: '上传失败', icon: 'none' }); }
});
},
previewImage(url) { uni.previewImage({ urls: [url], current: url }); },
async toggleVoiceInput() {
if (!this.voiceInputActive) {
try { await this.checkPrivacyAuthorize(); } catch (e) { return; }
uni.getSetting({
success: (res) => {
if (res.authSetting['scope.record']) {
this.hasRecordPermission = true;
this.voiceInputActive = true;
} else {
uni.authorize({
scope: 'scope.record',
success: () => {
this.hasRecordPermission = true;
this.voiceInputActive = true;
},
fail: () => {
uni.showModal({
title: '录音权限', content: '需要录音权限才能使用语音输入',
showCancel: true, confirmText: '去设置',
success: (res) => { if (res.confirm) uni.openSetting(); }
});
}
});
}
}
});
} else { this.voiceInputActive = false; }
},
scrollToBottom() {
this.scrollIntoView = '';
this.$nextTick(() => { setTimeout(() => { this.scrollIntoView = 'chat-bottom-anchor'; setTimeout(() => { if (this.scrollIntoView !== 'chat-bottom-anchor') this.scrollIntoView = 'chat-bottom-anchor'; }, 100); }, 300); });
},
scrollToOldLastMessage() {
this.scrollIntoView = '';
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list-container').boundingClientRect((rect) => {
if (rect) {
const heightDiff = rect.height - this.oldScrollViewHeight;
if (heightDiff > 0) {
this.scrollTop = heightDiff;
this.oldScrollViewHeight = rect.height;
}
}
}).exec();
});
},
async loadHistory() {
if (!this.hasMore || this.loading) return;
this.getScrollHeight();
this.isLoadHistory = true;
this.loading = true;
if (typeof ChatManager !== 'undefined') ChatManager.loadHistory(this.doctorUserInfo.room_id);
setTimeout(() => {
this.loading = false;
}, 3000);
},
getStatusText(status) { const map = { 0: '待审核', 1: '已审核', 2: '未通过' }; return map[status] || '未知状态'; },
getRegisterStatusText(status) { const map = { 0: '待就诊', 1: '已缴费', 2: '已就诊', 3: '已取消', 4: '已退费' }; return map[status] || '未知状态'; },
viewPrescription(id, orderNo) { uni.navigateTo({ url: "/subPackages/my/myrecord-detail?id=" + id + '&no=' + orderNo }); },
goToOrderMedicine(id, orderNo) { uni.navigateTo({ url: "/subPackages/my/my-drug/drug-info?id=" + id + '&order_type=prescription' }); },
viewRegister(id, orderNo) { uni.navigateTo({ url: `/subPackages/register/register-info?id=${id}&type=1` }); },
viewOrder(orderId, orderNo) { uni.navigateTo({ url: "/subPackages/my/my-drug/drug-info?id=" + orderId }); },
// 处理返回操作(自定义返回逻辑)
handleBack() {
if (this.showAgreement) {
this.closeAgreement();
return false;
}
// 跳转到首页tabBar页面需要使用switchTab
uni.switchTab({
url: '/pages/index/index',
success: () => {
console.log('跳转到首页成功');
},
fail: (err) => {
console.error('跳转到首页失败:', err);
// 如果switchTab失败尝试使用reLaunch
uni.reLaunch({
url: '/pages/index/index'
});
}
});
// 返回 false 阻止 u-navbar 的默认返回行为(重要!)
return false;
}
}
};
</script>
<style lang="scss" scoped>
// ================= 变量定义 =================
$primary-blue: #0A84FF;
$bg-page: #F4F6F9;
$bg-white: #ffffff;
// ================= 基础容器 =================
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $bg-page;
}
// ================= 顶部导航栏 =================
.chat-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
z-index: 100;
position: relative;
box-shadow: 0 1rpx 2rpx rgba(0,0,0,0.05);
// u-navbar会自动处理状态栏和导航栏高度这里不需要额外设置margin-top
.header-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
.doctor-info-group {
display: flex;
flex-direction: column;
.doctor-name { font-size: 34rpx; font-weight: 600; color: #333; margin-bottom: 4rpx; }
.doctor-badges {
display: flex;
align-items: center;
.status-dot { width: 12rpx; height: 12rpx; background-color: #52c41a; border-radius: 50%; margin-right: 8rpx; }
.sub-text { font-size: 24rpx; color: #999; }
}
}
.header-action {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
background: #F0F5FF;
border-radius: 32rpx;
.expand-tip { font-size: 24rpx; color: $primary-blue; margin-right: 6rpx; }
}
}
}
.doctor-detail-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
background: $bg-white;
&.panel-show { max-height: 300rpx; border-bottom: 1rpx solid #eee; }
.detail-grid {
padding: 24rpx 32rpx;
display: flex;
flex-wrap: wrap;
gap: 24rpx;
.detail-row {
width: 45%;
display: flex;
flex-direction: column;
&.full-width { width: 100%; }
.label { font-size: 22rpx; color: #999; margin-bottom: 8rpx; }
.value { font-size: 26rpx; color: #333; line-height: 1.4; }
.tag-value { color: $primary-blue; background: #e6f2ff; padding: 2rpx 8rpx; border-radius: 4rpx; display: inline-block; width: fit-content;}
.desc-value { color: #666; }
}
}
}
// ================= 消息滚动区域 =================
.chat-messages {
flex: 1;
background-color: $bg-page;
box-sizing: border-box;
}
.message-list-container {
padding: 30rpx 24rpx;
}
.notice-box {
margin-bottom: 20rpx;
padding: 24rpx;
background: #F5F5F5;
border-radius: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #666;
.notice-text {
display: block;
word-break: break-all;
}
.notice-link {
color: #0A84FF;
text-decoration: underline;
}
}
.empty-state { text-align: center; padding: 40rpx 0; .empty-text { font-size: 24rpx; color: #ccc; } }
.loading-spinner { display: flex; justify-content: center; align-items: center; padding: 20rpx; .spinner { width: 30rpx; height: 30rpx; border: 2rpx solid #ddd; border-top-color: #999; border-radius: 50%; animation: spin 0.8s linear infinite; } .spinner-text { font-size: 24rpx; color: #999; margin-left: 10rpx;} }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.time-divider { text-align: center; margin: 30rpx 0; .time-text { font-size: 22rpx; color: #aaa; background: rgba(0,0,0,0.03); padding: 4rpx 16rpx; border-radius: 8rpx; } }
// ================= 底部输入 =================
.chat-footer {
background-color: #F7F7F7; // 微信风格浅灰背景
border-top: 1rpx solid rgba(0,0,0,0.05);
padding: 16rpx 24rpx 40rpx 24rpx;
position: fixed;
bottom: 0; left: 0; right: 0; z-index: 99;
transition: bottom 0.2s ease-out;
.consultation-ended-notice { display: flex; align-items: center; justify-content: center; padding: 30rpx; color: #999; font-size: 28rpx; gap: 12rpx; }
.input-bar {
display: flex; align-items: flex-end; gap: 16rpx;
// 图标按钮
.icon-btn {
width: 72rpx; height: 72rpx;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
&:active { opacity: 0.6; }
}
// 输入框容器
.input-field-wrapper {
flex: 1;
min-height: 72rpx;
display: flex; align-items: center;
.main-input {
width: 100%; height: 72rpx;
padding: 0 20rpx;
font-size: 30rpx; color: #333;
background: #fff;
border-radius: 12rpx;
}
.voice-btn {
width: 100%; height: 72rpx;
background: #fff; border-radius: 12rpx;
display: flex; align-items: center; justify-content: center;
font-size: 30rpx; color: #333; font-weight: 500;
border: 1rpx solid rgba(0,0,0,0.05);
&.recording { background: #f0f0f0; color: #666; }
}
}
// 右侧发送/加号
.right-action {
height: 72rpx; display: flex; align-items: center; justify-content: center;
.send-btn {
background: $primary-blue; color: #fff;
font-size: 26rpx; padding: 0 24rpx; height: 60rpx; line-height: 60rpx;
border-radius: 8rpx;
}
}
}
}
.recording-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 999; display: flex; align-items: center; justify-content: center;
.recording-box {
width: 300rpx; height: 300rpx; background: rgba(0,0,0,0.8); border-radius: 32rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; gap: 20rpx;
.wave-animation { width: 100rpx; height: 4rpx; background: $primary-blue; border-radius: 20rpx; }
.timer { font-size: 32rpx; font-weight: 600; }
.tip { font-size: 24rpx; color: #ccc; }
}
}
.agreement-popup {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.agreement-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 50rpx 20rpx;
border-bottom: 1rpx solid #e5e7eb;
background: #fff;
flex-shrink: 0;
}
.agreement-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #64748b;
line-height: 1;
}
.agreement-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
line-height: 1.5;
flex: 1;
min-width: 0;
}
.agreement-content {
flex: 1;
height: 0;
min-height: 200rpx;
padding: 30rpx 50rpx 40rpx;
box-sizing: border-box;
}
</style>