Files
xk-client-wx/subPackages/chat/chat.vue
2026-02-02 16:10:46 +08:00

942 lines
34 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">
<!-- ================= 顶部导航栏 (保持原样) ================= -->
<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 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"
/>
</view>
<!-- 底部锚点 -->
<view id="chat-bottom-anchor" style="height: 1px; width: 100%;"></view>
</view>
<!-- 底部静态占位 -->
<view style="height: 180rpx;"></view>
</scroll-view>
<!-- ================= 底部输入与工具栏 (重构优化版) ================= -->
<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 { checkDev } from '@/utils/utils';
// 根据环境获取上传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',
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,
};
},
onLoad: function (option) {
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;
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);
const nickName = this.doctorUserInfo.nick_name;
if (nickName && nickName !== 'undefined' && nickName !== 'null') {
uni.setNavigationBarTitle({ title: `${nickName}医生的对话` });
}
this.$nextTick(() => {
this.scrollToBottom();
});
uni.onKeyboardHeightChange(res => {
this.keyboardHeight = res.height;
if (res.height > 0) this.scrollToBottom();
});
},
onUnload() {
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
}
if (typeof ChatManager !== 'undefined') ChatManager.leaveRoom();
},
onShow() {
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();
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;
}
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;
}
},
// === 音频播放逻辑 ===
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);
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 - headerHeight - footerHeight;
this.scrollViewStyle = `height: ${scrollHeight}px;`;
}).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) {
if (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'); }); }
});
}
},
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-detail?id=" + id + '&no=' + orderNo }); },
viewOrder(orderId, orderNo) { uni.navigateTo({ url: "/subPackages/my/my-drug/drug-info?id=" + orderId }); }
}
};
</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);
.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;
}
.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; }
}
}
</style>