2277 lines
78 KiB
Vue
2277 lines
78 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="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>
|
||
<!-- <!– 拍照按钮 –>-->
|
||
<!-- <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 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>-->
|
||
<!-- <!– 语音状态图标 –>-->
|
||
<!-- <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> |