1903 lines
65 KiB
Vue
1903 lines
65 KiB
Vue
<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="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, 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>
|
||
|
||
<!-- 类型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, 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: 移除 u-loading-icon,使用纯 CSS 动画替代,避免 easycom 文件查找失败) -->
|
||
<view v-if="loading" class="loading-spinner">
|
||
<view class="spinner"></view>
|
||
<text style="font-size: 24rpx; color: #999; margin-left: 16rpx;">加载历史消息...</text>
|
||
</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 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 class="action-item" @click="openCamera">
|
||
<view class="icon-circle"><u-icon name="camera-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">
|
||
<!-- 文本发送图标 -->
|
||
<u-icon name="arrow-up" size="18" color="#fff" v-if="!voiceInputActive"></u-icon>
|
||
<!-- 语音状态图标 -->
|
||
<view class="voice-wave" v-else>
|
||
<u-icon name="mic-fill" size="16" color="#fff"></u-icon>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</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} from "@/request/api/im";
|
||
|
||
/**
|
||
* 辅助函数:按需解析消息内容
|
||
* 针对需要 JSON.parse 的消息类型进行处理
|
||
*/
|
||
function getParsedContent(messageType, messageContent) {
|
||
// 需要解析的类型:处方(4), 挂号(10), 经历(11), 商品(12), 结束(13)
|
||
if ([4, 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,
|
||
|
||
// 预置数据结构 (用于发送)
|
||
prescriptionData: {
|
||
id: 159,
|
||
order_no: "XY9478601753943762",
|
||
status: 0,
|
||
express_name: "李测试",
|
||
total_pay_price: "11.94"
|
||
},
|
||
registerData: {}
|
||
};
|
||
},
|
||
|
||
// 页面加载生命周期
|
||
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);
|
||
}
|
||
|
||
// 5. 设置导航栏标题
|
||
uni.setNavigationBarTitle({
|
||
title: `与${this.doctorUserInfo.nick_name}医生的对话`,
|
||
success: () => {
|
||
console.log('修改标题成功');
|
||
}
|
||
});
|
||
|
||
// 6. 初次渲染滚动到底部
|
||
// 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: {
|
||
/**
|
||
* 计算并设置 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;`;
|
||
|
||
console.log('Scroll-view height calculated:', {
|
||
windowHeight,
|
||
headerHeight,
|
||
footerHeight,
|
||
scrollHeight
|
||
});
|
||
}).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;
|
||
},
|
||
|
||
/**
|
||
* 异步获取挂号详情并更新消息内容 (保留原逻辑)
|
||
*/
|
||
getChatRegisterInfo(item, index) {
|
||
let that = this;
|
||
const parsedContent = getParsedContent(item.message_type, item.message_content);
|
||
getChatRegisterInfoApi({
|
||
id: parsedContent.id
|
||
}).then((res) => {
|
||
that.messages[index].message_content = JSON.stringify(res.data.result);
|
||
console.log('getChatRegisterInfo', that.messages[index]);
|
||
})
|
||
return item;
|
||
},
|
||
|
||
/**
|
||
* 历史消息加载完毕回调
|
||
*/
|
||
handNoMessageList() {
|
||
this.isEmpty = true;
|
||
this.hasMore = false;
|
||
},
|
||
|
||
/**
|
||
* 历史消息加载成功回调
|
||
* type: 1=滚动到底部, 0=保持位置
|
||
*/
|
||
handLoadMessageList(type = 0) {
|
||
this.loading = false;
|
||
if (typeof ChatManager !== 'undefined') {
|
||
this.messages = ChatManager.roomMessages[this.doctorUserInfo.room_id] || [];
|
||
}
|
||
if (type === 1) {
|
||
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 消息处理
|
||
*/
|
||
handleSocketMessage(data) {
|
||
if (typeof ChatManager !== 'undefined') {
|
||
ChatManager.handleIncomingMessage(data);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 监听新消息事件
|
||
*/
|
||
handleNewRoomMessage(message) {
|
||
console.log('handle', 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;
|
||
console.log('update message by id', message.id);
|
||
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;
|
||
console.log('replace temporary message with server message', message.id);
|
||
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;
|
||
console.log('update message by content', message.id);
|
||
return;
|
||
}
|
||
|
||
// 新消息,添加到列表
|
||
console.log('push new message', message.id);
|
||
this.messages.push(message);
|
||
},
|
||
|
||
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: 'http://127.0.0.1:18001/api/mobile/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.log(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);
|
||
|
||
// 注意:不调用 ChatManager.addMessageToRoom,避免触发 new-room-message 事件导致消息重复
|
||
// 服务器返回的消息会通过 WebSocket 触发 handleNewRoomMessage,由去重逻辑处理
|
||
|
||
// 构造请求数据 (发送给服务器)
|
||
const requestData = {
|
||
room_id: this.doctorUserInfo.room_id,
|
||
sender_user_id: 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 = '';
|
||
},
|
||
|
||
startVoiceRecording() {
|
||
if (this.recording) 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) {
|
||
console.log('播放音频:', getParsedContent(msg.message_type, msg.message_content));
|
||
// TODO: 实际音频播放逻辑,可使用 uni.createInnerAudioContext
|
||
// 设置 this.currentPlayingAudio = msg.id 实现动画效果
|
||
},
|
||
|
||
isPlaying(msg) {
|
||
return this.currentPlayingAudio === msg.id;
|
||
},
|
||
|
||
openMediaPicker(type) {
|
||
this.showMoreActions = false;
|
||
if (type === 'image') {
|
||
uni.chooseImage({
|
||
count: 9,
|
||
sourceType: ['album', 'camera'],
|
||
success: res => {
|
||
res.tempFilePaths.forEach(file => {
|
||
// 此处复用了上传音频的接口逻辑,可根据实际需求调整为图片上传接口
|
||
uni.uploadFile({
|
||
url: 'http://127.0.0.1:18001/api/mobile/chat-friends/upload-chat-file',
|
||
filePath: file,
|
||
name: 'file',
|
||
header: { "authorization": uni.getStorageSync('token') },
|
||
formData: {},
|
||
success: (uploadFileRes) => {
|
||
this.sendMessage({
|
||
type: 'image',
|
||
content: JSON.parse(uploadFileRes.data).result.url
|
||
});
|
||
},
|
||
fail: (res) => { console.log(res, '上传失败'); }
|
||
});
|
||
});
|
||
},
|
||
fail: (e) => { console.log('错误', e); },
|
||
});
|
||
}
|
||
},
|
||
|
||
openCamera() {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sourceType: ['camera'],
|
||
success: res => {
|
||
// 这里可以复用上面的上传逻辑
|
||
this.sendMessage({
|
||
type: 'image',
|
||
content: res.tempFilePaths[0] // 注意:实际应用中也需要上传
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
previewImage(url) {
|
||
uni.previewImage({
|
||
urls: [url],
|
||
current: url
|
||
});
|
||
},
|
||
|
||
toggleVoiceInput() {
|
||
if (!this.voiceInputActive) {
|
||
// 切换到语音前检查权限
|
||
uni.getSetting({
|
||
success: (res) => {
|
||
if (res.authSetting['scope.record']) {
|
||
this.hasRecordPermission = true;
|
||
this.voiceInputActive = true;
|
||
} else {
|
||
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,使用 $nextTick 和适当延时,再设置 scroll-into-view
|
||
* 根据文档,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';
|
||
}, 200); // 增加延时,等待图片等资源加载完成
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 加载历史消息后保持滚动位置
|
||
* 修复:先清空 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.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-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;
|
||
|
||
.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); }
|
||
}
|
||
</style> |