Files
xk-client-wx/subPackages/chat/chat.vue
2026-01-14 18:44:20 +08:00

1173 lines
52 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>
<!-- 根容器:使用 Flex 布局实现全屏高度 -->
<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="14" color="#999"></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">主任医师</text>
</view>
<view class="detail-row">
<text class="label">医院</text>
<text class="value">北京协和医院</text>
</view>
<view class="detail-row full-width">
<text class="label">擅长</text>
<text class="value">冠心病、高血压、心力衰竭、心律失常的诊断与药物治疗</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"
>
<!-- 动态 Padding 防止键盘遮挡 -->
<view class="message-list-container" id="msglist" :style="{ paddingBottom: (keyboardHeight > 0 ? keyboardHeight + 20 : 0) + 'px' }">
<!-- 加载指示器 -->
<view v-if="loading" class="loading-spinner">
<view class="spinner"></view>
<text style="font-size: 24rpx; color: #999; margin-left: 16rpx;">加载历史消息...</text>
</view>
<!-- 无更多消息 -->
<view v-if="isEmpty" class="empty-state">
<text>没有更多历史消息了</text>
</view>
<!-- 消息列表循环 -->
<view v-for="(msg, index) in messages" :key="index" class="message-wrapper">
<!-- >>>>> 情况 A: 对方发送的消息 (医生) <<<<< -->
<view v-if="getSenderIdWithoutPrefix(msg.sender_user_id) !== getCurrentUserIdWithoutPrefix()" class="chat-row other">
<image class="avatar" :src="doctorAvatar" mode="aspectFill"></image>
<view class="bubble-container">
<view class="message-bubble" :class="'type-' + msg.message_type">
<!-- 图片 -->
<image v-if="msg.message_type === 1" :src="getParsedContent(msg.message_type, msg.message_content)" mode="widthFix" class="media-content image" @click="previewImage(getParsedContent(msg.message_type, msg.message_content))"></image>
<!-- 视频 -->
<video v-else-if="msg.message_type === 3" :src="getParsedContent(msg.message_type, msg.message_content)" controls class="media-content video"></video>
<!-- 语音 -->
<view v-else-if="msg.message_type === 2" class="audio-content" @click="playAudio(msg)">
<u-icon name="volume" size="18" color="#333"></u-icon>
<text class="duration">{{ msg.duration }}''</text>
<view class="wifi-symbol" :class="{'active': isPlaying(msg)}">
<view class="wifi-circle first"></view><view class="wifi-circle second"></view><view class="wifi-circle third"></view>
</view>
</view>
<!-- 复杂卡片 -->
<view v-else-if="[4, 9, 10, 11, 12, 13].includes(msg.message_type)" class="card-bubble">
<!-- 类型10: 挂号 -->
<block v-if="msg.message_type === 10">
<view class="card-header">
<view class="title-group"><u-icon name="calendar-fill" size="18" color="#059669"></u-icon><text class="card-title">预约挂号</text></view>
<text class="status-tag" :class="'status-' + getParsedContent(msg.message_type, msg.message_content).status">{{ getRegisterStatusText(getParsedContent(msg.message_type, msg.message_content).status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">订单号</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).order_no }}</text></view>
<view class="info-row"><text class="label">就诊人</text><text class="value">{{ (getParsedContent(msg.message_type, msg.message_content).user_patient && getParsedContent(msg.message_type, msg.message_content).user_patient.name) || '未知' }}</text></view>
<view class="info-row highlight"><text class="label">费用</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).price }}</text></view>
</view>
<view class="card-footer"><button class="action-btn" @click="viewRegister(getParsedContent(msg.message_type, msg.message_content).id)">查看详情</button></view>
</block>
<!-- 类型11: 就诊经历 -->
<block v-else-if="msg.message_type === 11">
<view class="card-header">
<view class="title-group"><u-icon name="file-text-fill" size="18" color="#059669"></u-icon><text class="card-title">患者就诊经历</text></view>
</view>
<view class="card-body">
<view class="info-row" v-if="getParsedContent(msg.message_type, msg.message_content).drug_name"><text class="label">药品名称</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).drug_name }}</text></view>
<view class="text-block" v-if="getParsedContent(msg.message_type, msg.message_content).illness_info"><text class="block-label">症状描述</text><text class="block-content">{{ getParsedContent(msg.message_type, msg.message_content).illness_info }}</text></view>
<view class="info-row"><text class="label">是否就诊过</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_visited === '1' ? '是' : '否' }}</text></view>
<view class="info-row"><text class="label">是否使用过药品</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_used_drug === '1' ? '是' : '否' }}</text></view>
</view>
</block>
<!-- 类型4: 电子处方 -->
<block v-else-if="msg.message_type === 4">
<view class="card-header">
<view class="title-group"><u-icon name="order" size="18" color="#298dff"></u-icon><text class="card-title">电子处方</text></view>
<text class="status-tag blue">{{ getStatusText(getParsedContent(msg.message_type, msg.message_content).status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">诊断单号</text><text class="value small">{{ getParsedContent(msg.message_type, msg.message_content).order_no }}</text></view>
<view class="info-row"><text class="label">金额</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).total_pay_price }}</text></view>
</view>
<view class="card-footer split">
<text class="link-btn" @click="viewPrescription(getParsedContent(msg.message_type, msg.message_content).id, getParsedContent(msg.message_type, msg.message_content).order_no)">详情</text>
<button class="action-btn primary" @click="goToOrderMedicine(getParsedContent(msg.message_type, msg.message_content).order_id, getParsedContent(msg.message_type, msg.message_content).order_no)">一键购药</button>
</view>
</block>
<!-- 类型12: 商品 -->
<block v-else-if="msg.message_type === 12">
<view class="card-header">
<view class="title-group"><u-icon name="shopping-cart-fill" size="18" color="#2563eb"></u-icon><text class="card-title">推荐商品</text></view>
</view>
<view class="card-body">
<view class="info-row"><text class="label">名称</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).product_name }}</text></view>
<view class="info-row"><text class="label">价格</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).price }}</text></view>
</view>
</block>
<!-- 类型9: 系统消息 -->
<block v-else-if="msg.message_type === 9">
<view class="system-message-card" v-if="getParsedContent(msg.message_type, msg.message_content).type === 'price_update'">
<view class="price-update-notice"><u-icon name="info-circle-fill" size="18" color="#fa8c16"></u-icon><text class="notice-text">{{ getParsedContent(msg.message_type, msg.message_content).message }}</text></view>
<view class="price-detail"><text>订单号:{{ getParsedContent(msg.message_type, msg.message_content).order_no }}</text><text>应付金额:¥{{ getParsedContent(msg.message_type, msg.message_content).total_pay_price }}</text></view>
</view>
<view v-else class="system-message"><text>{{ msg.message_content }}</text></view>
</block>
<!-- 类型13: 结束问诊 -->
<block v-else-if="msg.message_type === 13">
<view class="card-header">
<view class="title-group"><u-icon name="checkmark-circle-fill" size="18" color="#059669"></u-icon><text class="card-title">问诊结束</text></view>
</view>
<view class="card-body">
<view class="text-block"><text class="block-content">{{ getParsedContent(msg.message_type, msg.message_content).reason || '本次服务已完成' }}</text></view>
</view>
</block>
<block v-else><view class="card-body"><text>不支持的消息类型</text></view></block>
</view>
<!-- 文本 -->
<text v-else class="text-content">{{ getParsedContent(msg.message_type, msg.message_content) }}</text>
</view>
</view>
</view>
<!-- >>>>> 情况 B: 我发送的消息 (患者) <<<<< -->
<view v-else-if="getSenderIdWithoutPrefix(msg.sender_user_id) === getCurrentUserIdWithoutPrefix()" class="chat-row mine">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="bubble-container">
<view class="message-bubble" :class="'type-' + msg.message_type">
<!-- 图片 -->
<image v-if="msg.message_type === 1" :src="getParsedContent(msg.message_type, msg.message_content)" mode="widthFix" class="media-content image" @click="previewImage(getParsedContent(msg.message_type, msg.message_content))"></image>
<!-- 视频 -->
<video v-else-if="msg.message_type === 3" :src="getParsedContent(msg.message_type, msg.message_content)" controls class="media-content video"></video>
<!-- 语音 -->
<view v-else-if="msg.message_type === 2" class="audio-content mine-audio" @click="playAudio(msg)">
<text class="duration">{{ msg.duration }}''</text>
<u-icon name="volume-fill" size="18" color="#fff"></u-icon>
</view>
<!-- 卡片: 我发送的卡片 -->
<view v-else-if="[4, 9, 10, 11, 12, 13].includes(msg.message_type)" class="card-bubble">
<!-- 类型10: 挂号 -->
<block v-if="msg.message_type === 10">
<view class="card-header">
<view class="title-group"><u-icon name="calendar-fill" size="18" color="#059669"></u-icon><text class="card-title">预约挂号</text></view>
<text class="status-tag" :class="'status-' + getParsedContent(msg.message_type, msg.message_content).status">{{ getRegisterStatusText(getParsedContent(msg.message_type, msg.message_content).status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">订单号</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).order_no }}</text></view>
<view class="info-row"><text class="label">就诊人</text><text class="value">{{ (getParsedContent(msg.message_type, msg.message_content).user_patient && getParsedContent(msg.message_type, msg.message_content).user_patient.name) || '未知' }}</text></view>
<view class="info-row highlight"><text class="label">费用</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).price }}</text></view>
</view>
<view class="card-footer"><button class="action-btn" @click="viewRegister(getParsedContent(msg.message_type, msg.message_content).id)">查看详情</button></view>
</block>
<!-- 类型11: 患者就诊经历 -->
<block v-else-if="msg.message_type === 11">
<view class="card-header">
<view class="title-group"><u-icon name="file-text-fill" size="18" color="#059669"></u-icon><text class="card-title">患者就诊经历</text></view>
</view>
<view class="card-body">
<view class="info-row" v-if="getParsedContent(msg.message_type, msg.message_content).drug_name"><text class="label">药品名称</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).drug_name }}</text></view>
<view class="text-block" v-if="getParsedContent(msg.message_type, msg.message_content).illness_info"><text class="block-label">症状描述</text><text class="block-content">{{ getParsedContent(msg.message_type, msg.message_content).illness_info }}</text></view>
<view class="info-row"><text class="label">是否就诊过</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_visited === '1' ? '是' : '否' }}</text></view>
<view class="info-row"><text class="label">是否使用过药品</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_used_drug === '1' ? '是' : '否' }}</text></view>
</view>
</block>
<!-- 类型4: 电子处方 (此前错误的位置) -->
<block v-else-if="msg.message_type === 4">
<view class="card-header">
<view class="title-group"><u-icon name="order" size="18" color="#298dff"></u-icon><text class="card-title">电子处方</text></view>
<text class="status-tag blue">{{ getStatusText(getParsedContent(msg.message_type, msg.message_content).status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">诊断单号</text><text class="value small">{{ getParsedContent(msg.message_type, msg.message_content).order_no }}</text></view>
<view class="info-row"><text class="label">金额</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).total_pay_price }}</text></view>
</view>
<view class="card-footer split">
<text class="link-btn" @click="viewPrescription(getParsedContent(msg.message_type, msg.message_content).id, getParsedContent(msg.message_type, msg.message_content).order_no)">详情</text>
<button class="action-btn primary" @click="goToOrderMedicine(getParsedContent(msg.message_type, msg.message_content).order_id, getParsedContent(msg.message_type, msg.message_content).order_no)">一键购药</button>
</view>
</block>
<!-- 类型12: 商品 -->
<block v-else-if="msg.message_type === 12">
<view class="card-header">
<view class="title-group"><u-icon name="shopping-cart-fill" size="18" color="#2563eb"></u-icon><text class="card-title">推荐商品</text></view>
</view>
<view class="card-body">
<view class="info-row"><text class="label">名称</text><text class="value">{{ getParsedContent(msg.message_type, msg.message_content).product_name }}</text></view>
<view class="info-row"><text class="label">价格</text><text class="value price">¥{{ getParsedContent(msg.message_type, msg.message_content).price }}</text></view>
</view>
</block>
<!-- 类型13: 结束问诊 -->
<block v-else-if="msg.message_type === 13">
<view class="card-header">
<view class="title-group"><u-icon name="checkmark-circle-fill" size="18" color="#059669"></u-icon><text class="card-title">问诊结束</text></view>
</view>
<view class="card-body">
<view class="text-block"><text class="block-content">{{ getParsedContent(msg.message_type, msg.message_content).reason || '本次服务已完成' }}</text></view>
</view>
</block>
<block v-else>
<view class="card-body"><text>不支持的消息类型</text></view>
</block>
</view>
<!-- 文本 -->
<text v-else class="text-content">{{ getParsedContent(msg.message_type, msg.message_content) }}</text>
</view>
</view>
</view>
<!-- 时间分割线 -->
<view class="time-divider" v-if="shouldShowTime(msg, index)">
{{ msg.created_at_text || msg.time }}
</view>
</view>
<!-- 底部锚点 -->
<view id="chat-bottom-anchor" style="height: 1px; width: 100%;"></view>
</view>
<!-- 底部静态占位 -->
<view style="height: 270rpx;"></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="20" color="#999"></u-icon>
<text>本次问诊已结束</text>
</view>
<template v-else>
<view class="toolbar-area">
<view class="action-grid">
<view class="action-item" @click="openMediaPicker('image')">
<view class="icon-circle"><u-icon name="photo-fill" size="20" color="#606266"></u-icon></view>
<text>相册</text>
</view>
</view>
</view>
<view class="input-bar">
<view class="input-field-wrapper">
<input
v-if="!voiceInputActive"
v-model="newMessage"
class="main-input"
placeholder="描述您的症状或问题..."
confirm-type="send"
@confirm="sendTextMessage"
:adjust-position="false"
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="send-btn-wrapper" v-if="newMessage.trim() || voiceInputActive">
<view class="send-btn" @click="sendTextMessage">
<image src="/static/chat/send2.png" style="width: 30rpx; height: 30rpx;" />
</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>正在录音 {{ recordingTime }}s</text>
<text class="tip">手指上滑取消</text>
</view>
</view>
</view>
</template>
<script>
/**
* 导入依赖
*/
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) {
console.error('解析消息内容失败:', e);
return messageContent;
}
}
return messageContent;
}
function getMessageTypeName(messageType) {
const types = {
0: 'text', 1: 'image', 2: 'audio', 3: 'video', 4: 'prescription', 5: 'file',
6: 'video-call', 7: 'audio-call', 8: 'file', 9: 'system', 10: 'register',
11: 'patient-experience', 12: 'product-card', 13: 'end-consultation',
};
return types[messageType] || 'unknown';
}
export default {
data() {
return {
doctorUserInfo: { id: 0, avatar: '', nick_name: '医生', room_id: '' },
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,
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);
const nickName = this.doctorUserInfo.nick_name;
if (!nickName || nickName === 'undefined' || nickName === 'null') {
this.fetchDoctorInfo(doctorId);
} else {
uni.setNavigationBarTitle({ title: `与${nickName}医生的对话` });
}
this.$nextTick(() => {
this.scrollToBottom();
setTimeout(() => { this.scrollToBottom(); }, 500);
});
uni.onKeyboardHeightChange(res => {
this.keyboardHeight = res.height;
if (res.height > 0) this.scrollToBottom();
});
},
onUnload() {
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() {
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: {
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,
department: doctorInfo.department || '',
title: doctorInfo.title || ''
};
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-)/, ''); },
getParsedContent(t, c) { return getParsedContent(t, c); },
getMessageTypeName(t) { return getMessageTypeName(t); },
getMessageTypeClass(type) { return `type-${type}`; },
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)
});
}
});
},
getChatRegisterInfo(item, index) {
const parsedContent = getParsedContent(item.message_type, item.message_content);
if (parsedContent._loaded || !parsedContent.id) return item;
this.fetchRegisterCardInfo(item, index, parsedContent.id);
return item;
},
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();
const requestData = {
room_id: this.doctorUserInfo.room_id,
sender_user_id: `user-${this.currentUserId}`,
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();
},
playAudio(msg) {}, // 需自行实现播放
isPlaying(msg) { return this.currentPlayingAudio === msg.id; },
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);
},
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 }); }
}
};
</script>
<style lang="scss" scoped>
// ================= 变量定义 =================
$primary-blue: #298dff;
$primary-light: #e6f2ff;
$text-main: #1a1a1a;
$text-secondary: #666666;
$text-placeholder: #999999;
$bg-page: #f2f4f7;
$bg-white: #ffffff;
$radius-bubble: 20rpx;
// ================= 基础容器 =================
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $bg-page;
}
// ================= 头部导航栏 =================
.chat-header {
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
z-index: 100;
position: relative;
.header-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.03);
.doctor-info-group {
display: flex;
flex-direction: column;
.doctor-name { font-size: 34rpx; font-weight: 600; color: $text-main; 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; box-shadow: 0 0 0 2rpx rgba(82, 196, 26, 0.2); }
.sub-text { font-size: 24rpx; color: $text-secondary; }
}
}
.header-action {
display: flex;
align-items: center;
padding: 12rpx 20rpx;
background: $primary-light;
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: $text-placeholder; margin-bottom: 8rpx; }
.value { font-size: 26rpx; color: $text-main; line-height: 1.4; }
}
}
}
// ================= 消息滚动区域 =================
.chat-messages {
flex: 1;
background-color: $bg-page;
box-sizing: border-box;
}
.message-list-container {
padding: 30rpx 24rpx;
transition: padding-bottom 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.empty-state {
text-align: center;
padding: 40rpx 0;
text { font-size: 24rpx; color: #ccc; background: rgba(0,0,0,0.03); padding: 8rpx 20rpx; border-radius: 20rpx; }
}
.time-divider { text-align: center; margin: 30rpx 0; font-size: 22rpx; color: #b0b0b0; }
// ================= 消息行 =================
.message-wrapper { margin-bottom: 40rpx; }
.chat-row {
display: flex;
align-items: flex-start;
max-width: 100%;
.avatar {
width: 80rpx; height: 80rpx;
border-radius: 50%;
border: 2rpx solid #fff;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
flex-shrink: 0;
}
.bubble-container { max-width: 72%; display: flex; flex-direction: column; }
&.other {
flex-direction: row;
.avatar { margin-right: 20rpx; }
.message-bubble {
background-color: $bg-white; color: $text-main;
border-radius: 4rpx $radius-bubble $radius-bubble $radius-bubble;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.03);
&.card-bubble { padding: 0; background: transparent; box-shadow: none; }
}
}
&.mine {
flex-direction: row-reverse;
.avatar { margin-left: 20rpx; }
.message-bubble {
background-color: $primary-blue; color: #fff;
border-radius: $radius-bubble 4rpx $radius-bubble $radius-bubble;
box-shadow: 0 4rpx 12rpx rgba(41, 141, 255, 0.25);
&.card-bubble { background: transparent; box-shadow: none; }
}
}
}
.message-bubble {
padding: 20rpx 28rpx;
font-size: 30rpx;
line-height: 1.6;
position: relative;
word-break: break-all;
&.type-1, &.type-3, &.type-4, &.type-9, &.type-10, &.type-11, &.type-12, &.type-13 {
padding: 0; overflow: visible; background: transparent; box-shadow: none; border: none; border-radius: 0;
}
.media-content {
border-radius: 16rpx; max-width: 100%;
&.image { max-width: 320rpx; }
&.video { width: 320rpx; height: 180rpx; }
}
}
.audio-content {
display: flex; align-items: center; min-width: 120rpx; gap: 12rpx;
&.mine-audio { flex-direction: row-reverse; }
.duration { font-weight: bold; font-size: 28rpx; }
.wifi-symbol { width: 30rpx; height: 30rpx; }
}
// ================= 卡片系统 =================
.card-bubble {
background-color: $bg-white !important;
border-radius: 24rpx !important;
width: 500rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06) !important;
border: 1rpx solid rgba(0,0,0,0.02);
color: $text-main !important;
.card-header {
padding: 24rpx; border-bottom: 1rpx solid #f5f5f5; display: flex; justify-content: space-between; align-items: center;
.title-group { display: flex; align-items: center; gap: 12rpx; .card-title { font-size: 30rpx; font-weight: 600; color: $text-main; } }
.status-tag {
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 8rpx; background: #f0f0f0; color: #666;
&.status-1, &.blue { background: rgba(41, 141, 255, 0.1); color: $primary-blue; }
&.status-0 { background: rgba(255, 153, 0, 0.1); color: #ff9900; }
}
}
.card-body {
padding: 24rpx; display: flex; flex-direction: column; gap: 16rpx;
.info-row {
display: flex; justify-content: space-between; align-items: baseline; font-size: 26rpx;
.label { color: $text-secondary; }
.value { color: $text-main; text-align: right; max-width: 70%; }
.price { color: #ff4d4f; font-weight: bold; font-size: 30rpx; }
.small { font-size: 24rpx; color: #999; }
}
.text-block {
background: #f9f9f9; padding: 16rpx; border-radius: 12rpx;
.block-label { display: block; font-size: 22rpx; color: #999; margin-bottom: 8rpx; }
.block-content { font-size: 26rpx; color: #333; line-height: 1.5; }
}
}
.card-footer {
padding: 20rpx 24rpx; border-top: 1rpx solid #f5f5f5;
.action-btn {
background: #fff; border: 1rpx solid #eee; color: $text-secondary; font-size: 26rpx; border-radius: 40rpx; line-height: 2.2;
&.primary { background: $primary-blue; color: #fff; border: none; box-shadow: 0 4rpx 12rpx rgba(41, 141, 255, 0.2); }
&:active { opacity: 0.8; }
}
&.split { display: flex; justify-content: space-between; align-items: center; .link-btn { color: $primary-blue; font-size: 26rpx; padding: 10rpx; } }
}
}
// ================= 底部输入 =================
.chat-footer {
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 16rpx 24rpx 40rpx 24rpx;
position: fixed;
bottom: 0; left: 0; right: 0; z-index: 99;
transition: bottom 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
.consultation-ended-notice { display: flex; align-items: center; justify-content: center; padding: 30rpx; color: #999; font-size: 28rpx; gap: 12rpx; }
.toolbar-area {
margin-bottom: 20rpx;
.action-grid {
display: flex; gap: 40rpx; padding-left: 12rpx;
.action-item {
display: flex; flex-direction: column; align-items: center; gap: 8rpx;
.icon-circle { width: 80rpx; height: 80rpx; background: #f7f8fa; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; }
text { font-size: 22rpx; color: #666; }
}
}
}
.input-bar {
display: flex; align-items: flex-end; gap: 20rpx;
.input-field-wrapper {
flex: 1; background: #f2f4f7; border-radius: 40rpx; min-height: 80rpx; display: flex; align-items: center; padding: 0 8rpx;
.main-input { flex: 1; height: 80rpx; padding: 0 24rpx; font-size: 30rpx; color: #333; }
.voice-btn {
flex: 1; height: 72rpx; margin: 4rpx; background: #fff; border-radius: 36rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #333; font-weight: 500; box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
&.recording { background: #ffecec; color: #ff4d4f; }
}
}
.send-btn-wrapper .send-btn {
width: 80rpx; height: 80rpx; background: $primary-blue; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 12rpx rgba(41, 141, 255, 0.3); transition: transform 0.1s;
&:active { transform: scale(0.9); }
}
}
}
.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: #298dff; border-radius: 20rpx; }
.tip { font-size: 24rpx; color: #ccc; }
}
}
.loading-spinner { display: flex; justify-content: center; align-items: center; padding: 20rpx; }
.spinner { width: 32rpx; height: 32rpx; border: 3rpx solid #e5e5e5; border-top-color: #999; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.system-message-card {
background: #fffbe6; border: 1px solid #ffe58f; border-radius: 12rpx; padding: 20rpx; margin: 20rpx auto; max-width: 80%;
.price-update-notice { display: flex; align-items: center; gap: 10rpx; margin-bottom: 10rpx; .notice-text { color: #d48806; font-size: 28rpx; font-weight: 500; } }
.price-detail { display: flex; flex-direction: column; gap: 6rpx; font-size: 24rpx; color: #666; padding-left: 28rpx; }
}
.system-message { text-align: center; color: #999; font-size: 24rpx; padding: 10rpx 0; }
</style>