Files
xk-client-wx/subPackages/chat/chat.vue

1903 lines
65 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 根容器使用 Flex 布局实现全屏高度 -->
<view class="chat-container">
<!-- ================= 顶部导航栏区域 ================= -->
<!-- 采用了毛玻璃效果 (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>