Files
xk-doctor-wx/subPackages/sub_reception/reception_chat.vue
2026-05-07 11:04:56 +08:00

1231 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">
<u-navbar class="chat-navbar" :is-back="true" :title="patientName || '转接方患者'" title-color="#000"></u-navbar>
<!-- 患者信息头部 -->
<view class="chat-header">
<view class="header-main">
<view class="patient-info-group">
<text class="patient-name">{{ patientName || '患者' }}</text>
<view class="patient-badges">
<view class="status-dot"></view>
<text class="sub-text">问诊中</text>
</view>
</view>
<view class="header-action" @click="goToPrescription">
<text class="expand-tip">开方</text>
<u-icon name="arrow-right" size="16" color="#6ACDBB"></u-icon>
</view>
</view>
</view>
<!-- 消息列表 -->
<scroll-view
class="chat-messages"
:scroll-top="scrollTop"
:scroll-into-view="scrollIntoView"
scroll-y
@scroll="onScroll"
@scrolltolower="loadHistory"
>
<view class="message-list-container">
<!-- 加载历史消息 -->
<view class="loading-spinner" v-if="loading && page > 1">
<view class="spinner"></view>
<text class="spinner-text">加载中...</text>
</view>
<!-- 消息列表 -->
<view
v-for="(msg, index) in messages"
:key="msg.id ? msg.id : `msg_${index}`"
:id="`msg-${index}`"
>
<!-- 时间分隔符 -->
<view class="time-divider" v-if="shouldShowTime(msg, index)">
<text class="time-text">{{ formatTime(msg.created_at || msg.timestamp) }}</text>
</view>
<!-- 消息气泡 -->
<view class="bubble-container" :class="{ 'mine': isMsgMine(msg), 'other': !isMsgMine(msg) }">
<image class="avatar" :src="getMsgAvatar(msg)" mode="aspectFill"></image>
<view class="content-wrapper">
<view class="message-bubble" :class="['type-' + msg.message_type]">
<!-- 文本消息 -->
<text v-if="msg.message_type === 0" class="text-content" user-select>{{ getParsedContent(msg) }}</text>
<!-- 图片消息 -->
<image
v-else-if="msg.message_type === 1"
:src="getParsedContent(msg)"
mode="widthFix"
class="media-content image"
@click="previewImage(getParsedContent(msg))">
</image>
<!-- 语音消息 -->
<view
v-else-if="msg.message_type === 2"
class="audio-content"
:style="{ width: getAudioWidth(msg.duration) }"
@click="playAudio(msg)">
<block v-if="isMsgMine(msg)">
<text class="duration">{{ msg.duration }}''</text>
<view class="voice-icon-wrap mine" :class="{ 'playing': isPlaying(msg) }">
<u-icon name="volume-fill" size="20" color="#fff"></u-icon>
</view>
</block>
<block v-else>
<view class="voice-icon-wrap other" :class="{ 'playing': isPlaying(msg) }">
<u-icon name="volume-fill" size="20" color="#333"></u-icon>
</view>
<text class="duration">{{ msg.duration }}''</text>
</block>
</view>
<!-- 处方卡片 -->
<view
v-else-if="msg.message_type === 4"
class="prescription-card"
@click="viewPrescription(getParsedContent(msg))">
<view class="card-header">
<u-icon name="file-text" size="20" color="#6ACDBB"></u-icon>
<text class="card-title">处方单</text>
</view>
<view class="card-content">
<text class="card-text">点击查看处方详情</text>
</view>
</view>
<!-- 挂号卡片 type10 -->
<view v-else-if="msg.message_type === 10" class="register-msg-card">
<view class="rmc-head">
<text class="rmc-title">预约挂号</text>
</view>
<view class="rmc-body">
<view class="rmc-row" v-if="getParsedContent(msg).order_no"><text class="k">订单号</text><text class="v">{{ getParsedContent(msg).order_no }}</text></view>
<view class="rmc-row" v-if="getParsedContent(msg).chief_complaint"><text class="k">主诉</text><text class="v">{{ getParsedContent(msg).chief_complaint }}</text></view>
<view class="rmc-row" v-if="registerCardDrugLine(msg)"><text class="k">所选药品</text><text class="v">{{ registerCardDrugLine(msg) }}</text></view>
<view class="rmc-row" v-if="getParsedContent(msg).number != null && getParsedContent(msg).number !== ''"><text class="k">数量</text><text class="v"> {{ getParsedContent(msg).number }} </text></view>
</view>
</view>
<!-- type11复诊助理 / 患者就诊经历 -->
<view v-else-if="msg.message_type === 11" class="register-msg-card">
<template v-if="getParsedContent(msg).flow === 'follow_up_drug'">
<view class="rmc-head"><text class="rmc-title">医生助理</text></view>
<view class="rmc-body">
<text class="rmc-q">{{ getParsedContent(msg).question }}</text>
<view class="rmc-row" v-for="(row, iix) in (getParsedContent(msg).drugs || [])" :key="'rxd-' + iix">
<text class="k">{{ row.name || '药品' }}</text>
<text class="v">×{{ row.quantity != null ? row.quantity : 1 }}</text>
</view>
<view class="rmc-row" v-if="followUpAnswerLine(msg)"><text class="k">患者选择</text><text class="v">{{ followUpAnswerLine(msg) }}</text></view>
</view>
</template>
<template v-else>
<view class="rmc-head"><text class="rmc-title">患者就诊经历</text></view>
<view class="rmc-body">
<view class="rmc-row" v-if="getParsedContent(msg).drug_name"><text class="k">药品</text><text class="v">{{ getParsedContent(msg).drug_name }}</text></view>
<view class="rmc-row"><text class="k">是否就诊过</text><text class="v">{{ getParsedContent(msg).has_visited === '1' ? '是' : '否' }}</text></view>
<view class="rmc-row"><text class="k">是否用过药</text><text class="v">{{ getParsedContent(msg).has_used_drug === '1' ? '是' : '否' }}</text></view>
</view>
</template>
</view>
<text v-else class="text-content">{{ formatUnknownMessage(msg) }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="messages.length === 0 && !loading">
<text class="empty-text">暂无消息</text>
</view>
</view>
</scroll-view>
<!-- 底部输入栏 -->
<view class="chat-footer" :style="{ bottom: keyboardHeight + 'px' }">
<view class="consultation-ended-notice" v-if="isConsultationEnded">
<u-icon name="info-circle" size="16" color="#999"></u-icon>
<text>问诊已结束</text>
</view>
<view class="input-bar" v-else>
<!-- 语音/文字切换 -->
<view class="icon-btn" @click="toggleVoiceInput">
<u-icon :name="voiceInputActive ? 'keyboard' : 'mic'" size="24" color="#666"></u-icon>
</view>
<!-- 输入框 -->
<view class="input-field-wrapper">
<input
v-if="!voiceInputActive"
v-model="newMessage"
class="main-input"
placeholder="输入消息..."
@confirm="sendTextMessage"
@focus="onInputFocus"
@blur="onInputBlur"
/>
<view
v-else
class="voice-btn"
:class="{ 'recording': recording }"
@touchstart="startVoiceRecording"
@touchend="stopVoiceRecording"
@touchcancel="stopVoiceRecording"
>
{{ recording ? '录音中...' : '按住说话' }}
</view>
</view>
<!-- 右侧操作 -->
<view class="right-action">
<view class="icon-btn" @click="chooseImage" v-if="!newMessage.trim()">
<u-icon name="photo" size="24" color="#666"></u-icon>
</view>
<view class="send-btn" @click="sendTextMessage" v-else>发送</view>
</view>
</view>
</view>
<!-- 录音遮罩 -->
<view class="recording-overlay" v-if="showRecordingModal">
<view class="recording-box">
<view class="wave-animation"></view>
<text class="timer">{{ formatRecordingTime(recordingTime) }}</text>
<text class="tip">松开结束录音</text>
</view>
</view>
</view>
</template>
<script>
import { chatRoomManager } from '@/utils/chat/chatRoomManager.js';
import { getPatientItem } from '@/api/reception.js';
import { req } from '@/common/js/index.js';
import { chatConfig } from '@/config/chat.js';
// 根据环境获取上传URL
function checkDev(key = '') {
switch (key) {
case 'dev':
return true;
default:
return false;
}
}
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, 14].includes(messageType)) {
try {
return typeof messageContent === 'object' ? messageContent : JSON.parse(messageContent);
} catch (e) {
return messageContent;
}
}
return messageContent;
}
export default {
data() {
return {
registerId: '',
roomId: '',
patientId: '',
patientName: '',
patientInfo: null,
messages: [],
newMessage: '',
voiceInputActive: false,
recording: false,
recordingTime: 0,
recordingTimer: null,
showRecordingModal: false,
currentPlayingAudio: null,
innerAudioContext: null,
scrollTop: 0,
scrollIntoView: '',
page: 1,
pageSize: 20,
hasMore: true,
loading: false,
isConsultationEnded: false,
keyboardHeight: 0,
currentUserId: '',
patientUserId: ''
};
},
onLoad(options) {
this.registerId = options.register_id || '';
this.roomId = options.room_id || '';
this.patientName = decodeURIComponent(options.patient_name || '患者');
this.patientId = options.patient_id || '';
// 设置当前用户ID医生
this.currentUserId = `doctor-${uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id')}`;
// 进入聊天室
if (this.roomId) {
chatRoomManager.enterRoom(this.roomId);
}
// 注册消息处理器
chatRoomManager.onMessage(this.handleChatMessage);
// 获取患者信息
if (this.patientId) {
this.getPatientInfo();
}
// 加载历史消息
this.loadHistory();
// 监听键盘高度变化
uni.onKeyboardHeightChange(res => {
this.keyboardHeight = res.height;
if (res.height > 0) {
this.scrollToBottom();
}
});
},
onShow() {
// 如果房间ID存在确保已进入房间
if (this.roomId) {
chatRoomManager.enterRoom(this.roomId);
}
},
onHide() {
this.stopAudio();
},
onUnload() {
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
}
// 离开聊天室
chatRoomManager.leaveRoom();
// 移除消息处理器
chatRoomManager.offMessage(this.handleChatMessage);
},
mounted() {
this.recorderManager = uni.getRecorderManager();
this.initRecorder();
},
methods: {
/**
* 处理聊天室消息
* @param {Object} message - 消息对象
* @param {string} roomId - 房间ID
* 职责:处理收到的聊天消息,更新消息列表
*/
handleChatMessage(message, roomId) {
// 只处理当前房间的消息
if (roomId === this.roomId) {
this.addMessage(message);
this.scrollToBottom();
}
},
// 获取患者信息
async getPatientInfo() {
try {
const res = await getPatientItem(this.patientId);
this.patientInfo = res;
if (res && res.user_patient) {
this.patientUserId = `user-${res.user_patient.id}`;
}
} catch (error) {
console.error('获取患者信息失败:', error);
}
},
/**
* 加载历史消息
* 职责:使用聊天室管理器加载历史消息
*/
async loadHistory() {
if (!this.hasMore || this.loading || !this.roomId) return;
this.loading = true;
try {
// 使用聊天室管理器加载消息
const lastMessageId = this.page === 1 ? 0 : chatRoomManager.lastMessageId;
const messages = await chatRoomManager.loadRoomMessages(this.roomId, lastMessageId);
if (messages && messages.length > 0) {
// 如果是首次加载,直接设置消息列表
if (this.page === 1) {
this.messages = messages;
} else {
// 否则追加到前面
this.messages = [...messages, ...this.messages];
}
this.page++;
} else {
this.hasMore = false;
}
} catch (error) {
console.error('加载历史消息失败:', error);
this.hasMore = false;
} finally {
this.loading = false;
}
},
/**
* 添加消息
* @param {Object} message - 消息对象
* 职责:添加消息到消息列表,处理消息类型转换
*/
addMessage(message) {
if (!message.message_content && message.content) {
message.message_content = message.content;
}
// 使用配置文件中的消息类型映射
if (message.message_type === undefined && message.type !== undefined) {
message.message_type = chatConfig.getMessageType(message.type);
}
// 检查消息是否已存在(避免重复)
const existingIndex = this.messages.findIndex(m => m.id === message.id);
if (existingIndex !== -1) {
this.messages[existingIndex] = message;
return;
}
this.messages.push(message);
// 如果是结束问诊消息,设置标志
if (message.message_type === chatConfig.messageTypes['end-consultation']) {
this.isConsultationEnded = true;
}
},
// 判断消息是否是自己发的
isMsgMine(msg) {
const senderId = msg.sender_user_id || '';
return senderId === this.currentUserId || senderId.replace(/^(user-|doctor-)/, '') === this.currentUserId.replace(/^(user-|doctor-)/, '');
},
// 获取消息头像
getMsgAvatar(msg) {
const sid = msg.sender_user_id || '';
if (sid === 'doctor_assistant') {
return 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
}
if (this.isMsgMine(msg)) {
// 医生头像
return uni.getStorageSync('doctor_avatar') || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
} else {
// 患者头像
return this.patientInfo?.user_patient?.avatar || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
}
},
registerCardDrugLine(msg) {
const pc = getParsedContent(msg.message_type, msg.message_content);
if (!pc || typeof pc !== 'object') return '';
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
return arr.map((d) => d && d.name).filter(Boolean).join('、');
}
if (pc.drug && pc.drug.name) return pc.drug.name;
return '';
},
followUpAnswerLine(msg) {
const pc = getParsedContent(msg.message_type, msg.message_content);
if (!pc || pc.flow !== 'follow_up_drug') return '';
const st = pc.answer_status;
if (st === 'yes' || st === '1') return '是,曾使用过';
if (st === 'no' || st === '0') return '否,未使用过';
return '';
},
formatUnknownMessage(msg) {
if (msg.message_content == null) return '[消息]';
if (typeof msg.message_content === 'object') return '[卡片消息]';
const s = String(msg.message_content);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
},
// 解析消息内容
getParsedContent(msg) {
return getParsedContent(msg.message_type, msg.message_content);
},
// 判断是否显示时间
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; // 5分钟
},
// 格式化时间
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
},
// 格式化录音时间
formatRecordingTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
},
// 获取语音消息宽度
getAudioWidth(duration) {
const minWidth = 120;
const maxWidth = 300;
const width = minWidth + (duration / 60) * (maxWidth - minWidth);
return `${Math.min(width, maxWidth)}rpx`;
},
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
if (this.messages.length > 0) {
const lastIndex = this.messages.length - 1;
this.scrollIntoView = `msg-${lastIndex}`;
}
});
},
// 滚动事件
onScroll(e) {
// 可以在这里处理滚动逻辑
},
// 输入框聚焦
onInputFocus() {
this.scrollToBottom();
},
// 输入框失焦
onInputBlur() {
// 可以在这里处理失焦逻辑
},
// 切换语音输入
toggleVoiceInput() {
this.voiceInputActive = !this.voiceInputActive;
},
// 发送文本消息
sendTextMessage() {
if (!this.newMessage.trim()) return;
this.sendMessage({
type: 'text',
content: this.newMessage
});
this.newMessage = '';
},
/**
* 发送消息
* @param {Object} message - 消息对象
* 职责发送消息到聊天室先显示临时消息然后通过WebSocket发送
*/
sendMessage(message) {
const now = new Date();
const timeStr = this.formatTime(now);
// 使用配置文件中的消息类型映射
const messageType = chatConfig.getMessageType(message.type || 'text');
// 创建临时消息(用于立即显示)
const fullMessage = {
...message,
id: Date.now().toString(),
sender_user_id: this.currentUserId,
receiver_user_id: this.patientUserId,
room_id: this.roomId,
created_at_text: timeStr,
created_at: Math.floor(now.getTime() / 1000), // 转换为秒级时间戳
timestamp: Math.floor(now.getTime() / 1000),
message_content: message.message_content || message.content || '',
message_type: messageType,
isTemporary: true
};
// 先添加到消息列表(临时显示)
this.addMessage(fullMessage);
this.scrollToBottom();
// 使用聊天室管理器发送消息
const success = chatRoomManager.sendMessage({
room_id: this.roomId,
sender_user_id: this.currentUserId,
receiver_user_id: this.patientUserId,
message_type: messageType,
message_content: message.message_content || message.content || '',
duration: message.duration || 0
});
if (!success) {
console.error('消息发送失败');
// 可以在这里显示错误提示
}
},
// 选择图片
chooseImage() {
uni.chooseImage({
count: 1,
success: (res) => {
this.uploadImage(res.tempFilePaths[0]);
}
});
},
// 上传图片
uploadImage(filePath) {
uni.showLoading({ title: '上传中...', mask: true });
uni.uploadFile({
url: `${uploadBaseUrl}/chat-friends/upload-chat-file`,
filePath: filePath,
name: 'file',
header: {
"authorization": uni.getStorageSync('token')
},
success: (uploadRes) => {
try {
const data = JSON.parse(uploadRes.data);
if (data.result && data.result.url) {
this.sendMessage({
type: 'image',
content: data.result.url
});
}
} catch (e) {
console.error('解析上传结果失败:', e);
}
},
fail: (error) => {
console.error('上传失败:', error);
uni.showToast({ title: '上传失败', icon: 'none' });
},
complete: () => {
uni.hideLoading();
}
});
},
// 初始化录音器
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);
});
},
// 开始录音
startVoiceRecording() {
if (this.recording) return;
uni.getSetting({
success: (res) => {
if (res.authSetting['scope.record']) {
this.startRecording();
} else {
uni.authorize({
scope: 'scope.record',
success: () => {
this.startRecording();
},
fail: () => {
uni.showToast({ title: '需要录音权限', icon: 'none' });
}
});
}
}
});
},
// 开始录音
startRecording() {
this.recording = true;
this.showRecordingModal = true;
this.recordingTime = 0;
this.recordingTimer = setInterval(() => {
this.recordingTime++;
}, 1000);
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
},
// 停止录音
stopVoiceRecording() {
if (!this.recording) return;
this.recorderManager.stop();
},
// 上传音频文件
uploadAudioFile(tempFilePath, duration) {
uni.showLoading({ title: '上传中...', mask: true });
uni.uploadFile({
url: `${uploadBaseUrl}/chat-friends/upload-chat-file`,
filePath: tempFilePath,
name: 'file',
header: {
"authorization": uni.getStorageSync('token')
},
success: (uploadRes) => {
try {
const data = JSON.parse(uploadRes.data);
if (data.result && data.result.url) {
this.sendMessage({
type: 'audio',
content: data.result.url,
duration: duration
});
}
} catch (e) {
console.error('解析上传结果失败:', e);
}
},
fail: (error) => {
console.error('上传失败:', error);
uni.showToast({ title: '上传失败', icon: 'none' });
},
complete: () => {
uni.hideLoading();
}
});
},
// 播放音频
playAudio(msg) {
const audioUrl = this.getParsedContent(msg);
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;
},
// 预览图片
previewImage(url) {
uni.previewImage({
urls: [url],
current: url
});
},
// 查看处方
viewPrescription(content) {
if (content && content.id) {
uni.navigateTo({
url: `/subPackages/sub_reception/prescription_detail?id=${content.id}`
});
}
},
// 跳转到开方页面
goToPrescription() {
uni.navigateTo({
url: `/subPackages/sub_reception/reception_prescription?register_id=${this.registerId}&patient_id=${this.patientId}`
});
}
}
};
</script>
<style lang="scss" scoped>
$primary-color: #6ACDBB;
$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;
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;
.patient-info-group {
display: flex;
flex-direction: column;
.patient-name {
font-size: 34rpx;
font-weight: 600;
color: #333;
margin-bottom: 4rpx;
}
.patient-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-color;
margin-right: 6rpx;
}
}
}
}
.chat-messages {
flex: 1;
background-color: $bg-page;
box-sizing: border-box;
}
.message-list-container {
padding: 30rpx 24rpx;
}
.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;
}
}
.bubble-container {
display: flex;
margin-bottom: 30rpx;
&.mine {
flex-direction: row-reverse;
.content-wrapper {
align-items: flex-end;
}
.message-bubble {
background-color: $primary-color;
color: #fff;
}
}
&.other {
.content-wrapper {
align-items: flex-start;
}
.message-bubble {
background-color: #fff;
color: #333;
}
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin: 0 20rpx;
}
.content-wrapper {
display: flex;
flex-direction: column;
max-width: 60%;
.message-bubble {
padding: 20rpx;
border-radius: 16rpx;
word-wrap: break-word;
.text-content {
font-size: 28rpx;
line-height: 1.5;
}
.media-content {
max-width: 400rpx;
border-radius: 8rpx;
&.image {
max-height: 600rpx;
}
}
.audio-content {
display: flex;
align-items: center;
padding: 20rpx;
min-width: 120rpx;
.duration {
font-size: 24rpx;
margin: 0 10rpx;
}
.voice-icon-wrap {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.mine {
background-color: rgba(255,255,255,0.3);
}
&.other {
background-color: #f0f0f0;
}
&.playing {
animation: pulse 1s infinite;
}
}
}
.prescription-card {
background: #f5f5f5;
border-radius: 8rpx;
padding: 20rpx;
.card-header {
display: flex;
align-items: center;
margin-bottom: 10rpx;
.card-title {
font-size: 28rpx;
font-weight: 600;
margin-left: 10rpx;
}
}
.card-content {
.card-text {
font-size: 24rpx;
color: #666;
}
}
}
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.empty-state {
text-align: center;
padding: 40rpx 0;
.empty-text {
font-size: 24rpx;
color: #ccc;
}
}
.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-color;
color: #fff;
font-size: 26rpx;
padding: 0 24rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 8rpx;
}
}
}
}
.register-msg-card {
background: #fff;
border: 1rpx solid #e8e8e8;
border-radius: 12rpx;
padding: 16rpx 20rpx;
max-width: 520rpx;
.rmc-head {
margin-bottom: 12rpx;
.rmc-title { font-size: 28rpx; font-weight: 600; color: #333; }
}
.rmc-body {
display: flex;
flex-direction: column;
gap: 10rpx;
.rmc-q { font-size: 26rpx; color: #333; line-height: 1.4; }
.rmc-row {
display: flex;
justify-content: space-between;
font-size: 24rpx;
gap: 12rpx;
.k { color: #888; flex-shrink: 0; }
.v { color: #333; text-align: right; word-break: break-all; }
}
}
}
.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-color;
border-radius: 20rpx;
}
.timer {
font-size: 32rpx;
font-weight: 600;
}
.tip {
font-size: 24rpx;
color: #ccc;
}
}
}
</style>