Files
xk-client-wx/subPackages/chat/chat.vue
2026-01-09 13:58:09 +08:00

2277 lines
78 KiB
Vue
Raw Permalink 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">
<!-- ================= 顶部导航栏区域 ================= -->
<!-- 采用了毛玻璃效果 (backdrop-filter) 增加现代感 -->
<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>
<!-- 医生详情下拉面板:利用 max-height 实现平滑过渡动画 -->
<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-into-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">
<!-- 加载指示器 (移到顶部,避免滚动到底部时显示在底部) -->
<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">
<!-- 消息气泡主体 -->
<!-- 修复: UniApp 小程序编译不支持在 :class 中直接调用 methods 方法,改为内联字符串拼接 -->
<view class="message-bubble" :class="'type-' + msg.message_type">
<!-- 类型1: 图片消息 -->
<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>
<!-- 类型3: 视频消息 -->
<video
v-else-if="msg.message_type === 3"
:src="getParsedContent(msg.message_type, msg.message_content)"
controls
class="media-content video"
></video>
<!-- 类型2: 语音消息 -->
<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">
<!-- 1. 药品名称 (修正标签为'药品名称') -->
<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>
<!-- 2. 症状描述 (移到第二位) -->
<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>
<!-- 3. 是否就诊过 -->
<view class="info-row">
<text class="label">是否就诊过</text>
<text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_visited === '1' ? '是' : '否' }}</text>
</view>
<!-- 4. 是否使用过药品 -->
<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">
<!-- 修正: 详情跳转传参增加 order_no -->
<text class="link-btn" @click="viewPrescription(getParsedContent(msg.message_type, msg.message_content).id, getParsedContent(msg.message_type, msg.message_content).order_no)">详情</text>
<!-- 修正: 购买传参改为 order_id -->
<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>
<!-- 类型0: 纯文本消息 -->
<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">
<!-- 修复: 调整DOM顺序,将头像放在气泡前,配合 row-reverse 实现头像在最右侧 -->
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="bubble-container">
<!-- 修复: 同样将此处的 :class 函数调用改为内联拼接 -->
<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">
<!-- 1. 药品名称 -->
<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>
<!-- 2. 症状描述 -->
<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>
<!-- 3. 是否就诊过 -->
<view class="info-row">
<text class="label">是否就诊过</text>
<text class="value">{{ getParsedContent(msg.message_type, msg.message_content).has_visited === '1' ? '是' : '否' }}</text>
</view>
<!-- 4. 是否使用过药品 -->
<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作为滚动锚点,用于scroll-into-view定位到最底部 -->
<view id="chat-bottom-anchor" style="height: 1px; width: 100%;"></view>
</view>
<!-- 底部占位,防止被输入框遮挡 -->
<!-- Fix 1: 增加底部占位高度至 320rpx,确保消息不被悬浮工具栏遮挡 -->
<!-- Fix for blocking issue: Increased to 360rpx just to be super safe -->
<view style="height: 360rpx;"></view>
</scroll-view>
<!-- ================= 底部输入与工具栏 (重构版) ================= -->
<view class="chat-footer safe-area-inset-bottom">
<!-- 问诊已结束提示 -->
<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>
<!-- &lt;!&ndash; 拍照按钮 &ndash;&gt;-->
<!-- <view class="action-item" @click="openCamera">-->
<!-- <view class="icon-circle"><u-icon name="camera-fill" size="20" color="#606266"></u-icon></view>-->
<!-- <text>拍摄</text>-->
<!-- </view>-->
<!-- &lt;!&ndash; 视频按钮 &ndash;&gt;-->
<!-- <view class="action-item" @click="openMediaPicker('video')">-->
<!-- <view class="icon-circle"><u-icon name="play-circle-fill" size="20" color="#606266"></u-icon></view>-->
<!-- <text>视频</text>-->
<!-- </view>-->
</view>
</view>
<!-- 下半部分:输入框行 -->
<view class="input-bar">
<!-- 语音/键盘切换 -->
<!-- <view class="voice-switch" @click="toggleVoiceInput">-->
<!-- <u-icon :name="voiceInputActive ? 'keyboard' : 'mic'" size="28" color="#333"></u-icon>-->
<!-- </view>-->
<!-- 输入区域容器 -->
<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;" />
<!-- <uni-icons type="paperplane-filled"></uni-icons>-->
<!-- <u-icon name="arrow-up" size="18" color="#fff" v-if="!voiceInputActive"></u-icon>-->
<!-- &lt;!&ndash; 语音状态图标 &ndash;&gt;-->
<!-- <view class="voice-wave" v-else>-->
<!-- <u-icon name="mic-fill" size="16" color="#fff"></u-icon>-->
<!-- </view>-->
</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>
/**
* 导入依赖
* webSocketManager: WebSocket 管理类
* ChatManager: 聊天状态管理 Store
* API: 后端接口
*/
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';
/**
* 辅助函数:按需解析消息内容
* 针对需要 JSON.parse 的消息类型进行处理
*/
function getParsedContent(messageType, messageContent) {
// 需要解析的类型:处方(4), 挂号(10), 经历(11), 商品(12), 结束(13)
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: '', // 当前用户ID
doctorId: 'doctor-1', // 聊天对象ID
// 默认头像占位
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, // 当前播放的音频ID
hasRecordPermission: false, // 录音权限标记
// 滚动相关
scrollTop: 0, // 滚动条位置
scrollIntoView: '', // 修复: 增加 scrollIntoView 变量
scrollViewHeight: 0, // 滚动视图高度
oldScrollViewHeight: 0, // 加载历史消息前的滚动高度
scrollViewStyle: '', // 动态设置 scroll-view 高度样式
currentScrollPosition: 0, // 仅用于内部记录位置,不绑定到 view
// 分页相关
page: 1,
pageSize: 20,
hasMore: true,
loading: false,
isEmpty: false,
isLoadHistory: false, // 标记是否为触顶加载(而非初始化加载)
// 预置数据结构 (用于发送)
prescriptionData: {
id: 159,
order_no: "XY9478601753943762",
status: 0,
express_name: "李测试",
total_pay_price: "11.94"
},
registerData: {},
// 问诊是否已结束
isConsultationEnded: false
};
},
// 页面加载生命周期
onLoad: function (option) {
// 1. 初始化页面参数
this.doctorUserInfo = option;
if (option.avatar) {
this.doctorAvatar = option.avatar;
}
// 2. 初始化用户ID (加前缀逻辑)
this.currentUserId = 'user-' + uni.getStorageSync('user_id') || 'user-2512';
// 3. 处理医生ID,移除可能存在的后缀
let doctorId = option.user_id || '';
if (doctorId.includes('-type-')) {
doctorId = doctorId.split('-type-')[0];
}
this.doctorId = doctorId;
// 4. 初始化聊天管理器
if (typeof ChatManager !== 'undefined') {
ChatManager.enterRoom(option.room_id);
ChatManager.resetUnreadCount(option.room_id);
// 获取缓存的房间消息
this.messages = ChatManager.getRoomMessages(option.room_id);
// 检查房间状态是否为结束问诊(优先使用 option 参数)
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;
}
}
// 5. 从 API 获取最新房间状态(确保状态最新)
if (option.room_id) {
this.fetchRoomStatus(option.room_id);
}
// 6. 检查医生信息是否完整,如果不完整则从API获取
const nickName = this.doctorUserInfo.nick_name;
if (!nickName || nickName === 'undefined' || nickName === 'null') {
this.fetchDoctorInfo(doctorId);
} else {
// 设置导航栏标题
uni.setNavigationBarTitle({
title: `与${nickName}医生的对话`,
success: () => {}
});
}
// 7. 初次渲染滚动到底部
// Fix: 页面加载时延时触发滚动到锚点
this.$nextTick(() => {
this.scrollToBottom();
setTimeout(() => {
this.scrollToBottom();
}, 500); // 增加延时以等待图片渲染
});
},
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();
// 添加 WebSocket 消息处理器
if (typeof webSocketManager !== 'undefined') {
webSocketManager.addMessageHandler(this.handleSocketMessage);
}
// 计算并设置 scroll-view 的明确高度
this.calculateScrollViewHeight();
},
beforeDestroy() {
// 移除 WebSocket 消息处理器
if (typeof webSocketManager !== 'undefined') {
webSocketManager.removeMessageHandler(this.handleSocketMessage);
}
},
methods: {
/**
* 获取医生信息
* 当进入聊天页面时,如果医生信息不完整(undefined),则调用此方法获取
*/
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}医生的对话`,
success: () => {}
});
}
} catch (error) {
console.error('获取医生信息失败:', error);
// 即使获取失败,也设置一个默认标题
uni.setNavigationBarTitle({
title: '与医生的对话'
});
}
},
/**
* 获取房间状态
* 判断问诊是否已结束
*/
async fetchRoomStatus(roomId) {
try {
const res = await getRoomStatusApi({ room_id: roomId });
if (res.data && res.data.result) {
// 根据房间状态设置问诊结束状态(status=1结束,status=0正常)
this.isConsultationEnded = res.data.result.status === 1;
}
} catch (error) {
console.error('获取房间状态失败:', error);
}
},
/**
* 检查隐私授权
* 微信小程序从2023年9月起要求在使用敏感API前获取用户隐私授权
*/
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
});
},
/**
* 计算并设置 scroll-view 的明确高度
* 根据 uni-app 文档,scroll-view 需要明确的高度才能正常滚动
*/
calculateScrollViewHeight() {
this.$nextTick(() => {
const systemInfo = uni.getSystemInfoSync();
const windowHeight = systemInfo.windowHeight; // 窗口高度(px)
// 使用 uni.createSelectorQuery 获取头部和底部的高度
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;
// 计算 scroll-view 的可用高度
const scrollHeight = windowHeight - headerHeight - footerHeight;
// 设置样式(使用 px 单位,因为系统信息返回的是 px)
this.scrollViewStyle = `height: ${scrollHeight}px;`;
}).exec();
}).exec();
});
},
/**
* 滚动事件监听
* 记录当前滚动位置
*/
onScroll(e) {
// >>>>>>>>>> 修复的核心改动 >>>>>>>>>>
// 移除: this.scrollTop = e.detail.scrollTop;
// 原因: 实时将滚动位置赋值回绑定了 :scroll-top 的变量,会导致 scroll-view
// 在用户触摸时不断尝试"矫正"位置,从而引发画面抽搐。
// 只需记录位置供内部逻辑使用即可,不要去更新绑定的属性。
this.currentScrollPosition = e.detail.scrollTop;
},
/**
* 工具方法:移除 ID 前缀以便比较
* e.g. "doctor-123" -> "123"
*/
getSenderIdWithoutPrefix(senderId) {
if (!senderId) return '';
return String(senderId).replace(/^(user-|doctor-)/, '');
},
getCurrentUserIdWithoutPrefix() {
if (!this.currentUserId) return '';
return String(this.currentUserId).replace(/^(user-|doctor-)/, '');
},
// 暴露给模板使用
getParsedContent(messageType, messageContent) {
return getParsedContent(messageType, messageContent);
},
getMessageTypeName(messageType) {
return getMessageTypeName(messageType);
},
/**
* 获取CSS类名,用于区分消息类型样式
*/
getMessageTypeClass(type) {
return `type-${type}`;
},
/**
* 判断是否显示时间分割线
* 规则:第一条必显,后续如果与上一条间隔超过5分钟则显示
*/
shouldShowTime(msg, index) {
if (index === 0) return true;
const prevMsg = this.messages[index - 1];
// 5分钟 = 300,000 毫秒
// 兼容 created_at 和 timestamp 字段
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);
// 如果已加载或没有 id,跳过
if (parsedContent._loaded || !parsedContent.id) {
return;
}
// 调用 API 获取完整信息
that.fetchRegisterCardInfo(msg, index, parsedContent.id);
}
});
},
/**
* 获取单个挂号卡片的详细信息
* @param {Object} msg - 消息对象
* @param {Number} index - 消息在列表中的索引(仅作为备用)
* @param {Number} registerId - 挂号 ID
*/
fetchRegisterCardInfo(msg, index, registerId) {
let that = this;
// 保存消息 ID,可能是数字或字符串
const messageId = msg.id;
// 调试日志:确认 msg.id 的实际值
console.log('=== fetchRegisterCardInfo ===');
console.log('msg:', msg);
console.log('msg.id:', msg.id, typeof msg.id);
console.log('registerId:', registerId);
getChatRegisterInfoApi({
id: registerId,
message_id: messageId || '' // 确保传递,即使为空
}).then((res) => {
if (res.data && res.data.result) {
// 使用消息 ID 重新查找索引,而不是使用传入的 index
// 原因:异步请求期间消息列表可能发生变化,index 可能已失效
let targetIndex = index;
if (messageId) {
const foundIndex = that.messages.findIndex(m =>
m.id === messageId || String(m.id) === String(messageId)
);
if (foundIndex !== -1) {
targetIndex = foundIndex;
}
}
// 添加 _loaded 标识,避免重复请求
const resultWithFlag = {
...res.data.result,
_loaded: true
};
// 使用 $set 确保响应式更新
that.$set(that.messages, targetIndex, {
...that.messages[targetIndex],
message_content: JSON.stringify(resultWithFlag)
});
}
}).catch((err) => {
console.error('获取挂号信息失败:', err);
});
},
/**
* 异步获取挂号详情并更新消息内容(兼容旧调用方式)
* 后端会更新数据库中的 message_content,下次直接使用缓存
*/
getChatRegisterInfo(item, index) {
const parsedContent = getParsedContent(item.message_type, item.message_content);
// 如果已经加载过完整信息,直接返回不再请求
if (parsedContent._loaded) {
return item;
}
// 检查是否有 id
if (!parsedContent.id) {
return item;
}
this.fetchRegisterCardInfo(item, index, parsedContent.id);
return item;
},
/**
* 历史消息加载完毕回调
*/
handNoMessageList() {
this.isEmpty = true;
this.hasMore = false;
},
/**
* 历史消息加载成功回调
* type: 1=滚动到底部, 0=保持位置
* 修复:确保触顶加载时 type 始终为 0,只在初始化时传 type=1
*/
handLoadMessageList(type = 0) {
this.loading = false;
if (typeof ChatManager !== 'undefined') {
this.messages = ChatManager.roomMessages[this.doctorUserInfo.room_id] || [];
}
// 加载完消息后,检查是否有需要动态获取的挂号卡片
this.loadRegisterCards();
// 修复:如果是触顶加载(isLoadHistory=true),强制 type 为 0,保持滚动位置
// 只有在初始化加载时才滚动到底部(type=1 且 isLoadHistory=false)
const isHistoryLoad = this.isLoadHistory;
this.isLoadHistory = false; // 重置标记
const shouldScrollToBottom = type === 1 && !isHistoryLoad;
if (shouldScrollToBottom) {
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();
},
/**
* WebSocket 消息处理
* 直接处理当前房间消息,不走 ChatManager 避免重复
*/
handleSocketMessage(data) {
// 只处理当前房间的消息
if (data.room_id === this.doctorUserInfo.room_id) {
this.addMessage(data);
this.scrollToBottom();
}
},
/**
* 监听新消息事件
*/
handleNewRoomMessage(message) {
this.addMessage(message);
this.scrollToBottom();
},
/**
* 添加消息到列表并去重
* 修复:优先检查临时消息,放宽时间戳匹配条件,改进内容匹配逻辑(处理 JSON 格式差异)
*/
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;
}
// 检查是否已存在相同 ID 的消息
const existingIndexById = this.messages.findIndex(m => m.id === message.id);
if (existingIndexById !== -1) {
// 如果 ID 相同,更新现有消息(服务器返回的消息替换临时消息)
this.messages[existingIndexById] = message;
return;
}
// 辅助函数:标准化消息内容用于比较(处理 JSON 字符串和对象的差异)
const normalizeContent = (content) => {
if (!content) return '';
if (typeof content === 'string') {
try {
// 尝试解析 JSON 字符串
const parsed = JSON.parse(content);
return JSON.stringify(parsed);
} catch (e) {
// 不是 JSON,直接返回
return content;
}
} else if (typeof content === 'object') {
return JSON.stringify(content);
}
return String(content);
};
// 优先检查临时消息(isTemporary: true)
// 如果当前消息是服务器返回的(没有 isTemporary 标记),查找临时消息并替换
if (!message.isTemporary) {
const tempMessageIndex = this.messages.findIndex(m => {
// 查找临时消息,且发送者相同
if (!m.isTemporary) return false;
const mSenderId = m.sender_user_id || '';
const messageSenderId = message.sender_user_id || '';
if (mSenderId !== messageSenderId) return false;
// 比较消息内容(标准化后比较)
const mContent = normalizeContent(m.message_content || m.content || '');
const messageContent = normalizeContent(message.message_content || message.content || '');
if (mContent !== messageContent) return false;
// 时间戳匹配(放宽到30秒,处理网络延迟)
const mTime = m.created_at || m.timestamp || 0;
const messageTime = message.created_at || message.timestamp || 0;
return Math.abs(mTime - messageTime) < 30000; // 30秒
});
if (tempMessageIndex !== -1) {
// 找到临时消息,用服务器返回的消息替换
this.messages[tempMessageIndex] = message;
return;
}
}
// 检查是否已存在相似消息(内容+发送者+时间戳匹配,允许30秒内的误差)
// 用于处理乐观更新和服务器返回消息的重复问题
const messageContent = normalizeContent(message.message_content || message.content || '');
const senderId = message.sender_user_id || '';
const messageTime = message.created_at || message.timestamp || 0;
const existingIndexByContent = this.messages.findIndex(m => {
const mContent = normalizeContent(m.message_content || m.content || '');
const mSenderId = m.sender_user_id || '';
const mTime = m.created_at || m.timestamp || 0;
// 内容相同(标准化后)、发送者相同、时间差在30秒内
return mContent === messageContent &&
mSenderId === senderId &&
Math.abs(mTime - messageTime) < 30000; // 放宽到30秒
});
if (existingIndexByContent !== -1) {
// 如果找到相似消息,用服务器返回的消息替换临时消息(保留服务器 ID)
this.messages[existingIndexByContent] = 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) {
const newIndex = this.messages.length - 1;
this.fetchRegisterCardInfo(message, newIndex, parsedContent.id);
}
}
// 检测结束问诊消息,立即设置问诊结束状态
if (message.message_type === 13) {
this.isConsultationEnded = true;
}
// 收到任何消息后,都从 API 确认房间状态
if (this.doctorUserInfo.room_id) {
this.fetchRoomStatus(this.doctorUserInfo.room_id);
}
},
toggleDoctorDetail() {
this.showDoctorDetail = !this.showDoctorDetail;
},
/**
* 初始化录音功能
*/
initRecorder() {
// 监听录音结束事件
this.recorderManager.onStop(res => {
// 防止误触:录音时间小于1秒不发送
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);
uni.showToast({ title: '录音失败,请重试', icon: 'none' });
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: {}, // uni-app中uploadFile formData可以是对象
success: (uploadFileRes) => {
// 发送音频消息
this.sendMessage({
type: 'audio',
content: JSON.parse(uploadFileRes.data).result.url,
duration,
});
},
fail: (res) => {
console.error('上传失败', res);
}
});
} catch (error) {
console.error('音频上传失败:', error);
uni.showToast({ title: '音频上传失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
/**
* 核心发送消息逻辑
*/
sendMessage(message) {
const now = new Date();
const timeStr = this.formatTime(now);
// 处理接收者ID:移除 -type- 后缀,确保 doctor- 前缀
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 // 标记为临时消息,服务器返回后会替换
};
// Fix: Optimistic UI update - Add message locally immediately
this.addMessage(fullMessage);
// 发送消息后立即滚动到底部
this.scrollToBottom();
// 注意:不调用 ChatManager.addMessageToRoom,避免触发 new-room-message 事件导致消息重复
// 服务器返回的消息会通过 WebSocket 触发 handleNewRoomMessage,由去重逻辑处理
// 构造请求数据 (发送给服务器)
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).then(res => {
// Success (already scrolled by addMessage)
}).catch(error => {
console.error('发送失败:', error);
});
}
},
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++;
// 限制最长录音60秒
if (this.recordingTime >= 60) {
this.stopVoiceRecording();
}
}, 1000);
},
stopVoiceRecording() {
if (!this.recording) return;
this.recorderManager.stop();
},
playAudio(msg) {
// TODO: 实际音频播放逻辑,可使用 uni.createInnerAudioContext
// 设置 this.currentPlayingAudio = msg.id 实现动画效果
},
isPlaying(msg) {
return this.currentPlayingAudio === msg.id;
},
async openMediaPicker(type) {
this.showMoreActions = false;
// 先检查隐私授权
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');
});
},
fail: (e) => { console.error('选择图片错误', e); },
});
} else if (type === 'video') {
// 选择视频
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: 60, // 最长60秒
camera: 'back',
success: res => {
this.uploadAndSendFile(res.tempFilePath, 'video');
},
fail: (e) => { console.error('选择视频错误', e); },
});
}
},
/**
* 通用文件上传并发送方法
*/
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: result.message || '上传失败', icon: 'none' });
}
} catch (e) {
console.error('解析上传结果失败:', e);
uni.showToast({ title: '上传失败', icon: 'none' });
}
},
fail: (res) => {
uni.hideLoading();
console.error('上传失败', res);
uni.showToast({ title: '上传失败', icon: 'none' });
}
});
},
async openCamera() {
// 先检查隐私授权
try {
await this.checkPrivacyAuthorize();
} catch (e) {
return; // 用户拒绝隐私授权
}
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: res => {
// 使用通用上传方法上传拍摄的照片
this.uploadAndSendFile(res.tempFilePaths[0], 'image');
}
});
},
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: () => {
// 用户拒绝了授权,这时才需要引导去设置
this.hasRecordPermission = false;
uni.showModal({
title: '录音权限',
content: '需要录音权限才能使用语音输入,请在设置中开启',
showCancel: true,
cancelText: '取消',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting();
}
}
});
}
});
}
}
});
} else {
this.voiceInputActive = false;
}
},
/**
* 滚动到底部
* 修复:优化滚动逻辑,确保 scroll-into-view 正确设置,增加延时确保 DOM 完全更新
* 根据文档,scroll-into-view 优先级高于 scroll-top,需要避免同时设置
*/
scrollToBottom() {
// 先清空 scroll-into-view,避免与 scroll-top 冲突
// 注意:不清空 scrollTop,避免重置滚动位置
this.scrollIntoView = '';
this.$nextTick(() => {
// 等待 DOM 更新
setTimeout(() => {
// 设置 scroll-into-view 为底部锚点,触发滚动
this.scrollIntoView = 'chat-bottom-anchor';
// 如果第一次设置失败,再次尝试(确保滚动成功)
setTimeout(() => {
if (this.scrollIntoView !== 'chat-bottom-anchor') {
this.scrollIntoView = 'chat-bottom-anchor';
}
}, 100);
}, 300); // 增加延时,确保 DOM 完全更新和图片等资源加载完成
});
},
/**
* 加载历史消息后保持滚动位置
* 修复:先清空 scroll-into-view 避免冲突,使用 uni.createSelectorQuery 准确计算高度差
*/
scrollToOldLastMessage() {
// 先清空 scroll-into-view,避免优先级冲突
this.scrollIntoView = '';
this.$nextTick(() => {
// 使用 uni.createSelectorQuery 获取准确的滚动高度
const query = uni.createSelectorQuery().in(this);
query.select('.message-list-container').boundingClientRect((rect) => {
if (rect) {
const newHeight = rect.height;
// 计算新旧高度差,设置 scroll-top
const heightDiff = newHeight - this.oldScrollViewHeight;
if (heightDiff > 0) {
this.scrollTop = heightDiff;
// 更新旧高度记录
this.oldScrollViewHeight = newHeight;
}
}
}).exec();
});
},
async loadHistory() {
if (!this.hasMore || this.loading) return;
// 在加载历史消息前,记录当前滚动高度
this.getScrollHeight();
// 标记为触顶加载
this.isLoadHistory = true;
this.loading = true;
try {
if (typeof ChatManager !== 'undefined') {
ChatManager.loadHistory(this.doctorUserInfo.room_id);
}
} finally {
// loading状态通常在回调中设置为false
// 这里只是兜底,实际应在 load-room-message 事件中处理
}
},
// 状态文案辅助函数
getStatusText(status) {
const map = { 0: '待审核', 1: '已审核', 2: '未通过' };
return map[status] || '未知状态';
},
getRegisterStatusText(status) {
const map = { 0: '待就诊', 1: '已缴费', 2: '已就诊', 3: '已取消', 4: '已退费' };
return map[status] || '未知状态';
},
getPayStatusText(isPay) { return isPay === 1 ? '已支付' : '未支付'; },
getSexText(sex) { return sex === 1 ? '男' : '女'; },
// 页面跳转函数
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-base: 12rpx; // 基础圆角
$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;
}
.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;
// 这里可以添加CSS动画实现播放效果
}
}
// ================= 统一卡片系统 (Card System) =================
.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; }
}
}
}
// ================= 底部输入与工具栏 (Footer) =================
.chat-footer {
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 16rpx 24rpx 40rpx 24rpx; // 底部增加 Padding 适配 iPhone 底部条
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99;
// 问诊已结束提示
.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;
.voice-switch {
padding-bottom: 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;
// 可添加 keyframes 动画
}
.tip { font-size: 24rpx; color: #ccc; }
}
}
// 加载占位
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx;
}
// 纯CSS加载动画 (替代u-loading-icon)
.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>