Files
xk-doctor-wx/subPackages/sub_online_reception/pages/chat.vue

1916 lines
54 KiB
Vue
Raw Normal View History

<template>
<view class="chat-container">
<u-navbar class="chat-navbar" :is-back="true" :title="navbarTitle" title-color="#000"></u-navbar>
<view class="chat-header">
<view class="header-main" @click="detailOpen = !detailOpen">
<view class="patient-info-group">
<text class="patient-name">{{ navbarTitle }}</text>
<view class="patient-badges">
<view class="status-dot"></view>
<text class="sub-text">{{ statusLine }}</text>
</view>
</view>
<view class="header-action">
<u-icon :name="detailOpen ? 'arrow-up' : 'arrow-down'" size="18" color="#6ACDBB"></u-icon>
</view>
</view>
<PatientDetailPanel v-if="detailOpen && patientInfo" :info="patientInfo" />
</view>
<view v-if="isTransferRegister && transferCard" class="transfer-strip">
<text class="transfer-t">转诊处方{{ transferCard.prescription_no || '关联处方' }}</text>
<view class="transfer-strip-actions">
<view class="transfer-btn transfer-btn--ghost" @click.stop="openTransferDetail">详情</view>
<view v-if="canImportTransferRxStrip" class="transfer-btn transfer-btn--ghost" @click.stop="onTransferImportToPrescription">添加到处方</view>
<view class="transfer-btn" @click="goPrescriptionV2">去开方</view>
</view>
</view>
<scroll-view
class="chat-messages"
:scroll-top="scrollTop"
:scroll-into-view="scrollIntoView"
scroll-y
scroll-with-animation
upper-threshold="80"
@scrolltoupper="onScrollToUpper"
>
<view class="message-list-container">
<view v-if="!hasMore && messages.length > 0" class="empty-state empty-state--top">
<text class="empty-text">没有更多消息了</text>
</view>
<view v-if="historyLoading && page > 1" class="loading-spinner">
<text class="spinner-text">历史记录加载中...</text>
</view>
<view v-for="(msg, index) in messages" :key="msg._wxKey" class="message-wrapper">
<view v-if="shouldShowTime(msg, index)" class="time-divider">
<text class="time-text">{{ timeDividerText(msg) }}</text>
</view>
<MessageBubble
:msg="msg"
:is-mine="isMsgMine(msg)"
:avatar="getMsgAvatar(msg)"
:is-playing="isPlaying(msg)"
:can-add-experience-to-rx="canAddExperienceDrugFromChat"
@previewImage="previewImage"
@playAudio="playAudio"
@viewPrescription="onBubbleViewPrescription"
@addExperienceToRx="onAddExperienceToPrescription"
/>
</view>
<view id="chat-bottom-anchor" class="chat-bottom-anchor"></view>
<view v-if="messages.length === 0 && !bootstrapLoading" class="empty-state">
<text class="empty-text">暂无消息</text>
</view>
</view>
</scroll-view>
<ReceptionActionBar
:register-status="Number(patientInfo && patientInfo.status) || 0"
:room-ended="roomEnded"
@accept="onAccept"
@refuse="onRefuseTap"
@prescription="goPrescriptionV2"
@end="onEndDiagnosis"
/>
<view class="chat-footer" :style="{ bottom: keyboardHeight + 'px' }">
<view v-if="inputLocked" class="consultation-ended-notice">
<u-icon name="info-circle" size="16" color="#999"></u-icon>
<text>{{ lockReason }}</text>
</view>
<view v-else class="input-bar">
<view class="icon-btn" @click="toggleVoiceInput">
<u-icon :name="voiceInputActive ? 'keyboard' : 'mic'" size="24" color="#666"></u-icon>
</view>
<view class="input-field-wrapper">
<input
v-if="!voiceInputActive"
v-model="newMessage"
class="main-input"
placeholder="输入消息..."
confirm-type="send"
@confirm="sendTextMessage"
@focus="onInputFocus"
@blur="onInputBlur"
/>
<view
v-else
class="voice-btn"
:class="{ recording: recording }"
@touchstart="startVoiceRecording"
@touchend="stopVoiceRecording"
@touchcancel="stopVoiceRecording"
>
{{ recording ? '录音中...' : '按住说话' }}
</view>
</view>
<view class="right-action">
<view v-if="newMessage.trim()" class="send-btn" @click="sendTextMessage">发送</view>
<view class="icon-btn plus-wrap" @click="toggleToolPanel">
<text class="plus-char" :class="{ 'plus-char--open': showToolPanel }">+</text>
</view>
</view>
</view>
<view v-if="showToolPanel && !inputLocked" class="tool-panel-wrap">
<swiper
class="tool-panel-swiper"
:indicator-dots="true"
indicator-color="rgba(0,0,0,0.15)"
indicator-active-color="#6ACDBB"
:current="toolPanelSwiperCurrent"
@change="onToolPanelSwiperChange"
>
<swiper-item>
<view class="tool-grid">
<view class="tool-cell" @click="onMorePickImage">
<view class="tool-cell-icon"><u-icon name="photo" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">图片</text>
</view>
<view class="tool-cell" @click="onMoreQuickReply">
<view class="tool-cell-icon"><u-icon name="list" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">常用回复</text>
</view>
<view class="tool-cell" @click="onMorePatientInfo">
<view class="tool-cell-icon"><u-icon name="account" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">患者信息</text>
</view>
<view class="tool-cell" @click="onMoreOrdersPopup">
<view class="tool-cell-icon"><u-icon name="order" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">挂号订单</text>
</view>
<view class="tool-cell" :class="{ 'is-disabled': transferToolDisabled }" @click="onMoreTransferDetail">
<view class="tool-cell-icon"><u-icon name="share" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">转诊详情</text>
</view>
<view class="tool-cell" :class="{ 'is-disabled': goRxToolDisabled }" @click="onMoreGoPrescription">
<view class="tool-cell-icon"><u-icon name="edit-pen" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">去开方</text>
</view>
<view class="tool-cell" :class="{ 'is-disabled': experienceToolDisabled }" @click="onMoreExperienceToRx">
<view class="tool-cell-icon"><u-icon name="file-text" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">诊历带药</text>
</view>
<view class="tool-cell" :class="{ 'is-disabled': endToolDisabled }" @click="onMoreEndDiagnosis">
<view class="tool-cell-icon"><u-icon name="checkmark-circle" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">结束问诊</text>
</view>
</view>
</swiper-item>
<swiper-item>
<view class="tool-grid">
<view class="tool-cell" :class="{ 'is-disabled': refuseToolDisabled }" @click="onMoreRefuse">
<view class="tool-cell-icon"><u-icon name="close-circle" size="30" color="#6ACDBB"></u-icon></view>
<text class="tool-cell-label">拒诊</text>
</view>
<view v-for="ph in toolPlaceholderCells" :key="ph._wxKey" class="tool-cell tool-cell--placeholder"></view>
</view>
</swiper-item>
</swiper>
</view>
</view>
<view v-if="showQuickReplySheet" class="quick-reply-mask" @click="closeQuickReplySheet">
<view class="quick-reply-sheet" @click.stop>
<view class="quick-reply-head">
<text class="quick-reply-title">常用回复</text>
<text class="quick-reply-close" @click="closeQuickReplySheet">关闭</text>
</view>
<scroll-view v-if="!quickReplyLoading && quickReplyList.length" class="quick-reply-scroll" scroll-y>
<view
v-for="item in quickReplyList"
:key="item._wxKey"
class="quick-reply-row"
@click="selectQuickReply(item)"
>
<text class="quick-reply-text">{{ item.content }}</text>
</view>
</scroll-view>
<view v-else-if="quickReplyLoading" class="quick-reply-empty">加载中...</view>
<view v-else class="quick-reply-empty">暂无常用回复</view>
</view>
</view>
<view v-if="showRecordingModal" class="recording-overlay">
<view class="recording-box">
<text class="timer">{{ formatRecordingTime(recordingTime) }}</text>
<text class="tip">松开结束录音</text>
</view>
</view>
<RefuseReceptionModal ref="refuseModal" @confirm="onRefuseConfirm" />
<u-popup v-model="ordersPopupOpen" mode="bottom" border-radius="20" length="70%" :closeable="true" :safe-area-inset-bottom="true">
<view class="orders-popup" v-if="patientInfo">
<text class="orders-popup-title">挂号与订单</text>
<scroll-view scroll-y class="orders-popup-scroll">
<view v-if="registerOrderBlock" class="orders-block">
<view v-if="registerOrderBlock.order_no" class="orders-row">
<text class="orders-k">挂号单号</text>
<text class="orders-v order-font">{{ registerOrderBlock.order_no }}</text>
</view>
<view v-if="registerOrderBlock.price != null && registerOrderBlock.price !== ''" class="orders-row">
<text class="orders-k">费用</text>
<text class="orders-v price">¥{{ registerOrderBlock.price }}</text>
</view>
<view v-if="registerOrderBlock.status != null && registerOrderBlock.status !== ''" class="orders-row">
<text class="orders-k">状态</text>
<text class="orders-v">{{ registerOrderStatusText(registerOrderBlock.status) }}</text>
</view>
<view
v-for="sub in childOrdersWithKey"
:key="sub._wxKey"
class="orders-sub"
>
<text class="orders-sub-label">子订单</text>
<text class="orders-sub-no">{{ sub.order_no || sub.no || '—' }}</text>
</view>
</view>
<view v-else class="orders-hint">暂无挂号单扩展信息</view>
<text class="orders-section-title">本次挂号处方</text>
<view v-if="ordersRxLoading" class="orders-hint">加载中</view>
<view v-else-if="!ordersRxList.length" class="orders-hint">暂无处方</view>
<view
v-for="rx in ordersRxList"
:key="rx._wxKey"
class="orders-rx-row"
:data-wx-key="rx._wxKey"
@click="onOrdersRxTap"
>
<text class="orders-rx-no">{{ rx.order_no || rx.prescription_no || '处方' }}</text>
<text v-if="rx.total_pay_price != null" class="orders-rx-price">¥{{ rx.total_pay_price }}</text>
<text class="orders-rx-link">详情</text>
</view>
</scroll-view>
<view class="orders-popup-foot">
<view class="orders-popup-close" @click="ordersPopupOpen = false">关闭</view>
</view>
</view>
</u-popup>
<u-popup v-model="transferDetailOpen" mode="bottom" border-radius="20" length="65%" :closeable="true" :safe-area-inset-bottom="true">
<view class="transfer-detail-popup" v-if="isTransferRegister && transferCard">
<text class="td-title">转诊信息</text>
<scroll-view scroll-y class="td-scroll">
<view v-if="transferCard.prescription_no" class="td-row">
<text class="td-k">处方编号</text>
<text class="td-v">{{ transferCard.prescription_no }}</text>
</view>
<view v-if="transferCard.transfer_store && transferCard.transfer_store.name" class="td-row">
<text class="td-k">转诊诊所</text>
<text class="td-v">{{ transferCard.transfer_store.name }}</text>
</view>
<view v-if="transferCard.delegate_store && transferCard.delegate_store.name" class="td-row">
<text class="td-k">委托诊所</text>
<text class="td-v">{{ transferCard.delegate_store.name }}</text>
</view>
<view v-if="transferCard.transfer_reason" class="td-row">
<text class="td-k">转诊原因</text>
<text class="td-v">{{ transferCard.transfer_reason }}</text>
</view>
<view v-if="transferCard.status != null" class="td-row">
<text class="td-k">状态</text>
<text class="td-v">{{ transferStatusText(transferCard.status) }}</text>
</view>
<view v-if="transferCard.questions && transferCard.questions.length" class="td-qa">
<text class="td-qa-title">咨询问题</text>
<view v-for="(qa, qix) in transferQuestionsWithKey" :key="qa._wxKey" class="td-qa-item">
<text class="td-qa-q">{{ qix + 1 }}. {{ qa.question }}</text>
<text class="td-qa-a">{{ qa.answer }}</text>
</view>
</view>
</scroll-view>
<view class="td-foot td-foot--row">
<view v-if="canImportTransferRxStrip" class="transfer-btn transfer-btn--ghost" @click="onTransferImportToPrescription">添加到处方</view>
<view class="transfer-btn" @click="transferDetailOpen = false">关闭</view>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import { chatRoomManager } from '@/utils/chat/chatRoomManager.js';
import { chatConfig } from '@/config/chat.js';
import {
getPatientItem,
getQuickReplyListApi,
getRegisterPrescriptionList,
receptionApi,
refuseReceptionApi,
endOfDiagnosisApi,
getTransferPrescriptionByRegisterApi,
getDrugsByRegisterIdApi,
getCurrentStoreTypeApi
} from '@/api/reception.js';
import {
parseTransferPrescriptionContent,
transferDrugToChineseRow,
canImportTransferPrescription,
normalizeTransferCardPayload
} from '../utils/transferPrescriptionImport.js';
import {
getChatRoomByRegisterApi,
getRoomStatusApi,
uploadChatFileApi,
sendToUserHttpApi,
isSendToUserResponseOk,
sendToUserFailMessage,
extractSendToUserMessageId
} from '@/api/chat.js';
import { parseMessageContent } from '../utils/messageParse.js';
import PatientDetailPanel from '../components/PatientDetailPanel.vue';
import MessageBubble from '../components/MessageBubble.vue';
import ReceptionActionBar from '../components/ReceptionActionBar.vue';
import RefuseReceptionModal from '../components/RefuseReceptionModal.vue';
import { withWxKey, ensureWxKey } from '@/utils/wxListKey.js';
import { prepareImagePath } from '@/common/js/image-compress.js';
const PENDING_RX_FROM_CHAT = 'xk_pending_rx_from_chat';
const TOOL_PLACEHOLDER_CELLS = Array.from({ length: 7 }, (_, i) => ({ _wxKey: `ph${i}` }));
export default {
components: { PatientDetailPanel, MessageBubble, ReceptionActionBar, RefuseReceptionModal },
data() {
return {
registerId: '',
roomId: '',
patientName: '',
userPatientId: '',
patientInfo: null,
patientUserId: '',
messages: [],
newMessage: '',
voiceInputActive: false,
recording: false,
recordingTime: 0,
recordingTimer: null,
showRecordingModal: false,
currentPlayingAudio: null,
innerAudioContext: null,
scrollTop: 0,
scrollIntoView: '',
oldScrollViewHeight: 0,
page: 1,
hasMore: true,
bootstrapLoading: false,
historyLoading: false,
isConsultationEnded: false,
keyboardHeight: 0,
currentUserId: '',
detailOpen: false,
roomChatStatus: 0,
transferCard: null,
recorderManager: null,
showToolPanel: false,
showQuickReplySheet: false,
quickReplyList: [],
quickReplyLoading: false,
transferDetailOpen: false,
ordersPopupOpen: false,
ordersRxList: [],
ordersRxLoading: false,
toolPanelSwiperCurrent: 0,
toolPlaceholderCells: TOOL_PLACEHOLDER_CELLS
};
},
computed: {
navbarTitle() {
return this.patientName || '在线问诊';
},
patientAvatar() {
return this.patientInfo?.user_patient?.avatar || '';
},
doctorAvatar() {
return uni.getStorageSync('doctor_avatar') || '';
},
roomEnded() {
return this.roomChatStatus === 1 || this.isConsultationEnded;
},
inputLocked() {
if (this.roomEnded) return true;
const st = Number(this.patientInfo && this.patientInfo.status);
if (!st) return false;
if (st === 1 || st === 2) return false;
return true;
},
lockReason() {
if (this.roomChatStatus === 1 || this.isConsultationEnded) return '问诊已结束';
const st = Number(this.patientInfo && this.patientInfo.status);
if (st === 7 || st === 8) return '已拒诊';
if (st >= 3) return '当前状态不可发送消息';
return '不可发送';
},
statusLine() {
const st = Number(this.patientInfo && this.patientInfo.status);
const m = {
0: '待支付',
1: '待接诊',
2: '接诊中',
3: '已结束',
4: '已取消',
7: '已拒诊',
8: '已拒诊'
};
return m[st] || '问诊中';
},
canAddExperienceDrugFromChat() {
const st = Number(this.patientInfo && this.patientInfo.status);
return st === 2 && !this.inputLocked && !!this.registerId;
},
registerOrderBlock() {
const i = this.patientInfo;
return i && i.register_order ? i.register_order : null;
},
childOrdersBlock() {
const ro = this.registerOrderBlock;
if (!ro) return [];
const raw = ro.child_orders || ro.sub_orders || ro.orders;
return Array.isArray(raw) ? raw : [];
},
childOrdersWithKey() {
return withWxKey(this.childOrdersBlock, 'id', 'osub');
},
transferQuestionsWithKey() {
const qs = (this.transferCard && this.transferCard.questions) || [];
return withWxKey(qs, 'id', 'tdq');
},
refuseToolDisabled() {
return Number(this.patientInfo && this.patientInfo.status) !== 1;
},
endToolDisabled() {
if (this.roomEnded) return true;
return Number(this.patientInfo && this.patientInfo.status) !== 2;
},
transferToolDisabled() {
return !this.isTransferRegister || !this.transferCard;
},
experienceToolDisabled() {
return !this.canAddExperienceDrugFromChat;
},
goRxToolDisabled() {
return Number(this.patientInfo && this.patientInfo.status) !== 2;
},
canImportTransferRxStrip() {
return this.isTransferRegister && canImportTransferPrescription(this.transferCard);
},
isTransferRegister() {
const p = this.patientInfo;
if (!p) return false;
if (Number(p.is_from_transfer) === 1) return true;
const ro = p.register_order;
if (ro && Number(ro.is_from_transfer) === 1) return true;
const subs = p.child_orders || p.sub_orders || p.orders;
if (Array.isArray(subs)) {
return subs.some((c) => Number(c.is_from_transfer) === 1);
}
return false;
}
},
onLoad(options) {
this.registerId = options.register_id || '';
let rid = options.room_id || '';
try {
rid = rid ? decodeURIComponent(rid) : '';
} catch (e) {
rid = options.room_id || '';
}
this.roomId = rid;
try {
this.patientName = options.patient_name ? decodeURIComponent(options.patient_name) : '';
} catch (e) {
this.patientName = options.patient_name || '';
}
this.userPatientId = options.user_patient_id || '';
this.currentUserId = this.getHttpSenderUserId();
chatRoomManager.onMessage(this.handleChatMessage);
uni.onKeyboardHeightChange((res) => {
this.keyboardHeight = res.height || 0;
if (res.height > 0) this.scrollToBottom();
});
uni.$on('message-updated', this.onMessageUpdated);
this.bootstrap(options);
},
onShow() {
if (this.roomId) chatRoomManager.enterRoom(this.roomId);
this.refreshLatestChatMessages();
},
onHide() {
this.stopAudio();
},
onUnload() {
if (this.innerAudioContext) this.innerAudioContext.destroy();
chatRoomManager.leaveRoom();
chatRoomManager.offMessage(this.handleChatMessage);
uni.$off('message-updated', this.onMessageUpdated);
},
mounted() {
this.recorderManager = uni.getRecorderManager();
this.initRecorder();
},
methods: {
unwrap(res) {
if (res == null) return null;
if (res.result !== undefined && res.result !== null) return res.result;
if (res.data && res.data.result !== undefined) return res.data.result;
return res;
},
async bootstrap() {
if (!this.registerId) {
uni.showToast({ title: '缺少挂号信息', icon: 'none' });
return;
}
this.bootstrapLoading = true;
try {
await this.loadPatientDetail();
if (!this.roomId) {
const roomRes = await getChatRoomByRegisterApi(this.registerId);
const roomPayload = this.unwrap(roomRes) || {};
this.roomId = roomPayload.room_id || '';
if (roomPayload.receiver_user_id) this.patientUserId = roomPayload.receiver_user_id;
}
if (!this.patientUserId && this.patientInfo) {
const uid = this.patientInfo.user_id || this.patientInfo.user_patient?.user_id;
if (uid) this.patientUserId = `user-${uid}`;
else if (this.userPatientId) this.patientUserId = `user-${this.userPatientId}`;
}
if (this.roomId) {
chatRoomManager.enterRoom(this.roomId);
await this.refreshRoomStatus();
this.page = 1;
this.hasMore = true;
await this.loadHistory();
}
await this.loadTransferCard();
} catch (e) {
console.error(e);
uni.showToast({ title: '加载失败', icon: 'none' });
} finally {
this.bootstrapLoading = false;
}
},
async refreshLatestChatMessages() {
if (!this.roomId || this.bootstrapLoading || this.historyLoading) return;
try {
this.page = 1;
this.hasMore = true;
await this.loadHistory();
} catch (e) {
console.warn('refreshLatestChatMessages', e);
}
},
async loadPatientDetail() {
const res = await getPatientItem(this.registerId);
const data = this.unwrap(res);
this.patientInfo = data && typeof data === 'object' ? data : null;
if (this.patientInfo) {
if (!this.patientName && this.patientInfo.user_patient) {
this.patientName = this.patientInfo.user_patient.name || '';
}
const uid = this.patientInfo.user_id || this.patientInfo.user_patient?.user_id;
if (uid) this.patientUserId = `user-${uid}`;
}
},
async refreshRoomStatus() {
if (!this.roomId) return;
try {
const res = await getRoomStatusApi({ room_id: this.roomId });
const raw = this.unwrap(res) || {};
this.roomChatStatus = Number(raw.status) || 0;
if (this.roomChatStatus === 1) this.isConsultationEnded = true;
} catch (e) {
console.warn(e);
}
},
async loadTransferCard() {
try {
const res = await getTransferPrescriptionByRegisterApi(this.registerId);
const data = this.unwrap(res);
this.transferCard = normalizeTransferCardPayload(data);
} catch (e) {
this.transferCard = null;
}
},
syncMessagesFromRoom() {
const raw = chatRoomManager.getRoomMessages(this.roomId) || [];
this.messages = withWxKey(raw, 'id', 'm');
},
onMessageUpdated({ roomId }) {
if (roomId === this.roomId) {
this.syncMessagesFromRoom();
this.$forceUpdate();
}
},
handleChatMessage(message, roomId) {
if (roomId === this.roomId) {
this.addMessage(message);
this.scrollToBottom();
}
},
onScrollToUpper() {
if (this.bootstrapLoading) return;
this.loadHistory();
},
async loadHistory() {
if (!this.hasMore || this.historyLoading || !this.roomId) return;
const wasFirstPage = this.page === 1;
if (!wasFirstPage) {
await new Promise((resolve) => {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select('.message-list-container')
.boundingClientRect((res) => {
this.oldScrollViewHeight = res && res.height ? res.height : 0;
resolve();
})
.exec();
});
});
}
this.historyLoading = true;
try {
const lastMessageId = wasFirstPage ? 0 : chatRoomManager.lastMessageId;
const batch = await chatRoomManager.loadRoomMessages(this.roomId, lastMessageId);
if (batch && batch.length > 0) {
this.syncMessagesFromRoom();
this.page++;
} else {
this.hasMore = false;
}
} catch (e) {
console.error(e);
this.hasMore = false;
} finally {
this.historyLoading = false;
this.$nextTick(() => {
if (wasFirstPage && this.messages.length > 0) {
this.scrollToBottom();
} else if (!wasFirstPage) {
this.scrollToOldLastMessage();
}
});
}
},
addMessage(message) {
if (!message.message_content && message.content) {
message.message_content = message.content;
}
if (message.message_type === undefined && message.type !== undefined) {
message.message_type = chatConfig.getMessageType(message.type);
}
const normalizeContent = (c) => {
if (c == null || c === '') return '';
if (typeof c === 'string') {
try {
return JSON.stringify(JSON.parse(c));
} catch (e) {
return c;
}
}
if (typeof c === 'object') return JSON.stringify(c);
return String(c);
};
if (!message.isTemporary) {
const tempIndex = this.messages.findIndex((m) => {
if (!m.isTemporary) return false;
if ((m.sender_user_id || '') !== (message.sender_user_id || '')) return false;
if (normalizeContent(m.message_content) !== normalizeContent(message.message_content)) return false;
const t1 = Number(m.created_at || m.timestamp || 0);
const t2 = Number(message.created_at || message.timestamp || 0);
const a = t1 > 1e12 ? t1 : t1 * 1000;
const b = t2 > 1e12 ? t2 : t2 * 1000;
return Math.abs(a - b) < 60000;
});
if (tempIndex !== -1) {
const patched = ensureWxKey({ ...message, isTemporary: false }, 'id', `m${tempIndex}`);
this.$set(this.messages, tempIndex, patched);
return;
}
}
const existingIndex = this.messages.findIndex((m) => m.id === message.id);
if (existingIndex !== -1) {
this.$set(this.messages, existingIndex, ensureWxKey(message, 'id', `m${existingIndex}`));
return;
}
this.messages.push(ensureWxKey(message, 'id', `m${this.messages.length}`));
if (message.message_type === chatConfig.messageTypes['end-consultation']) {
this.isConsultationEnded = true;
}
},
isMsgMine(msg) {
const senderId = msg.sender_user_id || '';
return senderId === this.currentUserId;
},
getMsgAvatar(msg) {
const sid = msg.sender_user_id || '';
const fallback = 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250108/29bdb9560340373bb3096394a845afa5.jpg';
if (sid === 'doctor_assistant') return fallback;
if (this.isMsgMine(msg)) {
return this.doctorAvatar || fallback;
}
return this.patientAvatar || fallback;
},
toMessageMs(t) {
const n = Number(t) || 0;
return n > 1e12 ? n : n * 1000;
},
shouldShowTime(msg, index) {
if (index === 0) return true;
const prev = this.messages[index - 1];
const currMs = this.toMessageMs(msg.created_at || msg.timestamp || 0);
const prevMs = this.toMessageMs(prev.created_at || prev.timestamp || 0);
return currMs - prevMs > 300000;
},
timeDividerText(msg) {
if (msg.created_at_text) return msg.created_at_text;
return this.formatTimeDivider(msg.created_at || msg.timestamp);
},
formatTimeDivider(ts) {
if (!ts && ts !== 0) return '';
const ms = Number(ts) > 1e12 ? Number(ts) : Number(ts) * 1000;
const d = new Date(ms);
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
},
formatClockTime(date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
},
formatRecordingTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
},
scrollToBottom() {
if (!this.messages.length) return;
this.scrollIntoView = '';
this.$nextTick(() => {
setTimeout(() => {
this.scrollIntoView = 'chat-bottom-anchor';
setTimeout(() => {
if (this.scrollIntoView !== 'chat-bottom-anchor') {
this.scrollIntoView = 'chat-bottom-anchor';
}
}, 100);
}, 300);
});
},
scrollToOldLastMessage() {
this.scrollIntoView = '';
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select('.message-list-container')
.boundingClientRect((rect) => {
if (!rect) return;
const heightDiff = rect.height - this.oldScrollViewHeight;
if (heightDiff > 0) {
const next = heightDiff;
this.scrollTop = this.scrollTop === next ? next + 0.01 : next;
this.oldScrollViewHeight = rect.height;
}
})
.exec();
});
},
onInputFocus() {
this.scrollToBottom();
},
onInputBlur() {},
toggleToolPanel() {
this.showToolPanel = !this.showToolPanel;
if (!this.showToolPanel) {
this.closeQuickReplySheet();
this.toolPanelSwiperCurrent = 0;
}
},
onToolPanelSwiperChange(e) {
const c = e && e.detail && e.detail.current;
this.toolPanelSwiperCurrent = typeof c === 'number' ? c : 0;
},
onMorePickImage() {
this.onToolPickImage();
},
onMoreQuickReply() {
this.onToolQuickReply();
},
onMorePatientInfo() {
this.showToolPanel = false;
if (this.detailOpen) {
uni.showToast({ title: '患者信息已在顶部展开', icon: 'none' });
return;
}
this.detailOpen = true;
},
async onMoreOrdersPopup() {
this.showToolPanel = false;
if (!this.patientInfo) {
uni.showToast({ title: '暂无患者信息', icon: 'none' });
return;
}
this.ordersPopupOpen = true;
await this.loadOrdersRxListForPopup();
},
async loadOrdersRxListForPopup() {
if (!this.registerId) {
this.ordersRxList = [];
return;
}
this.ordersRxLoading = true;
try {
const res = await getRegisterPrescriptionList({
register_id: this.registerId,
page: 1,
page_size: 30
});
const payload = this.unwrap(res) || {};
this.ordersRxList = withWxKey(Array.isArray(payload.list) ? payload.list : [], 'id', 'orx');
} catch (e) {
console.warn(e);
this.ordersRxList = [];
} finally {
this.ordersRxLoading = false;
}
},
registerOrderStatusText(status) {
const map = { 0: '待就诊', 1: '已缴费', 2: '已就诊', 3: '已取消', 4: '已退费' };
const n = Number(status);
return map[n] != null ? map[n] : String(status);
},
onOrdersRxTap(e) {
const wxKey = e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.wxKey;
const rx = this.ordersRxList.find((r) => r._wxKey === wxKey);
const id = rx && (rx.id || rx.prescription_id);
if (!id) return;
let url = `/subPackages/sub_my/prescriptionDetail?id=${id}`;
const no = rx.order_no || rx.prescription_no;
if (no) url += `&no=${encodeURIComponent(no)}`;
this.ordersPopupOpen = false;
uni.navigateTo({ url });
},
onMoreTransferDetail() {
if (this.transferToolDisabled) {
uni.showToast({ title: this.isTransferRegister ? '暂无转诊信息' : '当前挂号无转诊处方', icon: 'none' });
return;
}
this.showToolPanel = false;
this.openTransferDetail();
},
onMoreGoPrescription() {
this.showToolPanel = false;
if (this.goRxToolDisabled) {
uni.showToast({ title: '请先接诊', icon: 'none' });
return;
}
this.goPrescriptionV2();
},
onMoreExperienceToRx() {
if (this.experienceToolDisabled) {
uni.showToast({ title: '请先接诊后再开方', icon: 'none' });
return;
}
uni.showModal({
title: '诊历带药',
content: '此操作会覆盖当前的处方,是否确认?',
confirmText: '确认',
cancelText: '取消',
success: (modalRes) => {
if (!modalRes.confirm) return;
this.showToolPanel = false;
this.onRegisterDrugsBulkToPrescription();
}
});
},
async onRegisterDrugsBulkToPrescription() {
if (!this.canAddExperienceDrugFromChat) {
uni.showToast({ title: '请先接诊后再开方', icon: 'none' });
return;
}
uni.showLoading({ title: '加载药品…', mask: true });
try {
const rows = await this.fetchRegisterExperienceDrugRows();
if (!rows.length) {
uni.showToast({ title: '未找到药品信息', icon: 'none' });
return;
}
try {
uni.setStorageSync(
PENDING_RX_FROM_CHAT,
JSON.stringify({
source: 'register_drugs',
register_id: String(this.registerId),
drugs: rows
})
);
} catch (err) {
console.error(err);
uni.showToast({ title: '缓存失败', icon: 'none' });
return;
}
this.goPrescriptionV2();
} catch (e) {
console.error(e);
uni.showToast({ title: '加载失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
onTransferImportToPrescription() {
if (!this.isTransferRegister) {
uni.showToast({ title: '当前挂号无转诊处方', icon: 'none' });
return;
}
if (!this.transferCard || !canImportTransferPrescription(this.transferCard)) {
uni.showToast({ title: '当前不可导入', icon: 'none' });
return;
}
const parsed = parseTransferPrescriptionContent(this.transferCard);
if (!parsed || !parsed.drugList || !parsed.drugList.length) {
uni.showToast({ title: '处方内容为空', icon: 'none' });
return;
}
const rows = parsed.drugList.map((d) => transferDrugToChineseRow(d, parsed.prescription_type));
try {
uni.setStorageSync(
PENDING_RX_FROM_CHAT,
JSON.stringify({
source: 'transfer_import',
register_id: String(this.registerId),
drugs: rows,
diagnosisText: parsed.clinical_diagnose || '',
medicalAdvice: parsed.doctor_order || ''
})
);
} catch (err) {
console.error(err);
uni.showToast({ title: '缓存失败', icon: 'none' });
return;
}
this.transferDetailOpen = false;
this.goPrescriptionV2();
},
onMoreRefuse() {
if (this.refuseToolDisabled) return;
this.showToolPanel = false;
this.onRefuseTap();
},
onMoreEndDiagnosis() {
if (this.endToolDisabled) {
uni.showToast({ title: '当前不可结束问诊', icon: 'none' });
return;
}
this.showToolPanel = false;
this.onEndDiagnosis();
},
onToolPickImage() {
this.showToolPanel = false;
this.chooseImage();
},
async onToolQuickReply() {
this.showToolPanel = false;
this.showQuickReplySheet = true;
this.quickReplyList = [];
this.quickReplyLoading = true;
try {
const res = await getQuickReplyListApi();
const raw = this.unwrap(res);
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.list) ? raw.list : [];
this.quickReplyList = withWxKey(list, 'id', 'qr');
} catch (e) {
console.error(e);
uni.showToast({ title: '加载常用回复失败', icon: 'none' });
this.quickReplyList = [];
} finally {
this.quickReplyLoading = false;
}
},
closeQuickReplySheet() {
this.showQuickReplySheet = false;
},
selectQuickReply(item) {
const t = item && (item.content != null ? String(item.content) : '');
this.newMessage = t;
this.closeQuickReplySheet();
this.showToolPanel = false;
},
toggleVoiceInput() {
this.voiceInputActive = !this.voiceInputActive;
},
sendTextMessage() {
if (!this.newMessage.trim() || !this.roomId || this.inputLocked) return;
this.sendMessage({ type: 'text', content: this.newMessage });
this.newMessage = '';
this.showToolPanel = false;
},
sendMessage(message) {
if (!this.roomId || !this.patientUserId || this.inputLocked) return;
const now = new Date();
const messageType = chatConfig.getMessageType(message.type || 'text');
const tempId = `tmp_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const fullMessage = {
...message,
id: tempId,
sender_user_id: this.getHttpSenderUserId(),
receiver_user_id: this.patientUserId,
room_id: this.roomId,
created_at_text: this.formatClockTime(now),
created_at: now.getTime(),
timestamp: now.getTime(),
message_content: message.message_content || message.content || '',
message_type: messageType,
isTemporary: true
};
this.addMessage(fullMessage);
this.scrollToBottom();
this.postSendMessageHttp(fullMessage, tempId, messageType, message.duration || 0);
},
getHttpSenderUserId() {
const raw = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id');
const n = parseInt(raw, 10);
if (!Number.isNaN(n) && n > 0) return `doctor-${n}`;
return raw && String(raw).startsWith('doctor-') ? String(raw) : `doctor-${raw}`;
},
async postSendMessageHttp(fullMessage, tempId, messageType, duration) {
const payload = {
room_id: this.roomId,
sender_user_id: this.getHttpSenderUserId(),
receiver_user_id: this.patientUserId,
message_type: messageType,
message_content: fullMessage.message_content || '',
duration: duration || 0,
isFromHttp: true
};
try {
const res = await sendToUserHttpApi(payload);
if (!isSendToUserResponseOk(res)) {
throw { res };
}
const mid = extractSendToUserMessageId(res);
if (mid != null && mid !== '') {
const idx = this.messages.findIndex((m) => m.id === tempId);
if (idx !== -1) {
this.$set(this.messages[idx], 'id', mid);
this.$set(this.messages[idx], '_wxKey', String(mid));
this.$set(this.messages[idx], 'isTemporary', false);
}
}
} catch (e) {
const res = e && e.res != null ? e.res : e && e.data != null ? e.data : e;
const idx = this.messages.findIndex((m) => m.id === tempId);
if (idx !== -1) this.messages.splice(idx, 1);
uni.showToast({ title: sendToUserFailMessage(res, e), icon: 'none' });
}
},
chooseImage() {
if (this.inputLocked) return;
uni.chooseImage({
count: 1,
success: async (res) => {
const tempFile = res.tempFiles && res.tempFiles[0];
const filePath = tempFile ? tempFile.path : res.tempFilePaths[0];
let fileSize = tempFile ? tempFile.size : 0;
if (!fileSize && filePath) {
try {
const info = await new Promise((resolve, reject) => {
uni.getFileInfo({
filePath,
success: resolve,
fail: reject,
});
});
fileSize = info.size;
} catch (error) {
console.error(error);
}
}
const prepared = await prepareImagePath({
path: filePath,
size: fileSize,
});
if (!prepared) return;
this.uploadMedia(prepared.path, 'image');
},
});
},
async uploadMedia(filePath, kind) {
uni.showLoading({ title: '上传中...', mask: true });
try {
const res = await uploadChatFileApi({ filePath, name: 'file' });
const inner = this.unwrap(res);
const url = inner && (inner.url || inner.path);
if (!url) {
uni.showToast({ title: '上传失败', icon: 'none' });
return;
}
if (kind === 'image') {
this.sendMessage({ type: 'image', content: url });
} else if (kind === 'audio') {
const d = this._pendingAudioDuration || 1;
this.sendMessage({ type: 'audio', content: url, duration: d });
}
} catch (e) {
console.error(e);
uni.showToast({ title: '上传失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
initRecorder() {
this.recorderManager.onStop((res) => {
if (res.duration < 1000) {
uni.showToast({ title: '录音时间太短', icon: 'none' });
this._finishRecordingUi();
return;
}
const sec = Math.floor(res.duration / 1000);
this._pendingAudioDuration = sec;
this.uploadMedia(res.tempFilePath, 'audio');
this._finishRecordingUi();
});
this.recorderManager.onError(() => {
this._finishRecordingUi();
});
},
_finishRecordingUi() {
this.recording = false;
this.showRecordingModal = false;
if (this.recordingTimer) clearInterval(this.recordingTimer);
this.recordingTimer = null;
},
startVoiceRecording() {
if (this.inputLocked || this.recording) return;
uni.getSetting({
success: (res) => {
if (res.authSetting['scope.record']) {
this._startRecording();
} else {
uni.authorize({
scope: 'scope.record',
success: () => this._startRecording(),
fail: () => uni.showToast({ title: '需要录音权限', icon: 'none' })
});
}
}
});
},
_startRecording() {
this.recording = true;
this.showRecordingModal = true;
this.recordingTime = 0;
this.recordingTimer = setInterval(() => {
this.recordingTime++;
}, 1000);
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
},
stopVoiceRecording() {
if (!this.recording) return;
this.recorderManager.stop();
},
playAudio(msg) {
const parsed = parseMessageContent(msg.message_type, msg.message_content);
const finalUrl = typeof parsed === 'string' ? parsed : (parsed && parsed.url) || '';
if (!finalUrl) {
uni.showToast({ title: '无效的音频地址', icon: 'none' });
return;
}
if (this.currentPlayingAudio === msg.id) {
this.stopAudio();
return;
}
this.stopAudio();
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.onEnded(() => {
this.currentPlayingAudio = null;
});
this.innerAudioContext.onError(() => {
this.currentPlayingAudio = null;
uni.showToast({ title: '播放失败', icon: 'none' });
});
}
this.innerAudioContext.src = finalUrl;
this.innerAudioContext.play();
this.currentPlayingAudio = msg.id;
},
stopAudio() {
if (this.innerAudioContext) this.innerAudioContext.stop();
this.currentPlayingAudio = null;
},
isPlaying(msg) {
return this.currentPlayingAudio === msg.id;
},
previewImage(url) {
uni.previewImage({ urls: [url], current: url });
},
viewPrescription(content) {
const id = content && content.id;
if (id) {
uni.navigateTo({ url: `/subPackages/sub_my/prescriptionDetail?id=${id}` });
}
},
onBubbleViewPrescription(idOrContent, orderNo) {
if (idOrContent != null && typeof idOrContent === 'object') {
this.viewPrescription(idOrContent);
return;
}
const id = idOrContent;
if (id) {
let url = `/subPackages/sub_my/prescriptionDetail?id=${id}`;
if (orderNo) url += `&no=${encodeURIComponent(orderNo)}`;
uni.navigateTo({ url });
}
},
openTransferDetail() {
if (!this.isTransferRegister || !this.transferCard) return;
this.transferDetailOpen = true;
},
transferStatusText(st) {
const n = Number(st);
const m = { 0: '待处理', 1: '已同意', 3: '已拒绝' };
return m[n] != null ? m[n] : `状态${st}`;
},
async resolveStoreIdForRegisterDrugs() {
try {
const res = await getCurrentStoreTypeApi({ register_id: this.registerId });
const raw = this.unwrap(res) || {};
const sid =
Number(raw.is_from_transfer) === 1 && raw.delegate_store_id
? Number(raw.delegate_store_id)
: Number(raw.store_id) || 0;
return sid > 0 ? sid : 0;
} catch (e) {
console.warn(e);
return 0;
}
},
async fetchRegisterExperienceDrugRows() {
const storeId = await this.resolveStoreIdForRegisterDrugs();
if (!storeId) {
uni.showToast({ title: '无法获取门店,请稍后重试', icon: 'none' });
return [];
}
const res = await getDrugsByRegisterIdApi({
register_id: this.registerId,
store_id: storeId
});
const list = this.unwrap(res);
return Array.isArray(list) ? list : [];
},
onAddExperienceToPrescription(payload) {
if (!this.canAddExperienceDrugFromChat) {
uni.showToast({ title: '请先接诊后再开方', icon: 'none' });
return;
}
const drugName = payload && (payload.drug_name || payload.name);
if (!drugName) {
uni.showToast({ title: '无药品名称', icon: 'none' });
return;
}
this._applyExperienceDrugWithOptionalFilter(payload);
},
async _applyExperienceDrugWithOptionalFilter(payload) {
uni.showLoading({ title: '加载药品…', mask: true });
try {
const rows = await this.fetchRegisterExperienceDrugRows();
if (rows.length) {
const wantId = payload && payload.drug_id != null && String(payload.drug_id).trim() !== '' ? String(payload.drug_id) : '';
const wantName = String(payload.drug_name || payload.name || '').trim();
let picked = rows;
if (wantId) {
picked = rows.filter((r) => {
const did = r.drug_id != null ? String(r.drug_id) : r.drug && r.drug.id != null ? String(r.drug.id) : '';
return did === wantId;
});
}
if (!picked.length && wantName) {
picked = rows.filter((r) => {
const dn = (r.drug && r.drug.drug_name) || r.drug_name || '';
return String(dn).trim() === wantName;
});
}
if (picked.length) {
try {
uni.setStorageSync(
PENDING_RX_FROM_CHAT,
JSON.stringify({
source: 'register_drugs',
register_id: String(this.registerId),
drugs: picked
})
);
} catch (err) {
console.error(err);
}
uni.hideLoading();
this.goPrescriptionV2();
return;
}
}
} catch (e) {
console.warn(e);
} finally {
uni.hideLoading();
}
try {
uni.setStorageSync(
PENDING_RX_FROM_CHAT,
JSON.stringify({
register_id: String(this.registerId),
drug_name: String(payload.drug_name || payload.name || ''),
drug_id: payload && payload.drug_id != null ? String(payload.drug_id) : ''
})
);
} catch (err2) {
console.error(err2);
}
this.goPrescriptionV2();
},
goPrescriptionV2() {
const st = Number(this.patientInfo && this.patientInfo.status);
if (st !== 2) {
uni.showToast({ title: '请先接诊', icon: 'none' });
return;
}
const pid = this.userPatientId || this.patientInfo?.user_patient_id || this.patientInfo?.user_patient?.id || '';
let url = `/subPackages/sub_workbench/prescription_v2/index?register_id=${this.registerId}`;
if (pid) url += `&patient_id=${pid}`;
uni.navigateTo({ url });
},
async onAccept() {
try {
await receptionApi(this.registerId);
uni.showToast({ title: '接诊成功', icon: 'none' });
await this.loadPatientDetail();
} catch (e) {
uni.showToast({ title: (e && e.msg) || '接诊失败', icon: 'none' });
}
},
onRefuseTap() {
this.$refs.refuseModal && this.$refs.refuseModal.open();
},
async onRefuseConfirm(reason) {
try {
await refuseReceptionApi({ register_id: this.registerId, refuse_reason: reason });
uni.showToast({ title: '已拒诊', icon: 'none' });
await this.loadPatientDetail();
} catch (e) {
uni.showToast({ title: (e && e.msg) || '操作失败', icon: 'none' });
}
},
async onEndDiagnosis() {
uni.showModal({
title: '结束诊断',
content: '确认结束本次诊断?',
success: async (r) => {
if (!r.confirm) return;
try {
await endOfDiagnosisApi(this.registerId);
uni.showToast({ title: '已结束', icon: 'none' });
await this.loadPatientDetail();
await this.refreshRoomStatus();
} catch (e) {
uni.showToast({ title: (e && e.msg) || '操作失败', icon: 'none' });
}
}
});
}
}
};
</script>
<style lang="scss" scoped>
$primary-color: #6acdbb;
$bg-page: #f4f6f9;
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $bg-page;
}
.chat-navbar {
flex-shrink: 0;
}
.chat-header {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.05);
}
.header-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
}
.patient-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.patient-badges {
display: flex;
align-items: center;
margin-top: 8rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $primary-color;
margin-right: 8rpx;
}
.sub-text {
font-size: 24rpx;
color: #888;
}
.transfer-strip {
margin: 16rpx 24rpx;
padding: 20rpx 24rpx;
background: #fff7e6;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
}
.transfer-strip .transfer-t {
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
.transfer-strip-actions {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 52%;
}
.transfer-btn {
color: $primary-color;
font-weight: 500;
}
.transfer-btn--ghost {
color: #666;
font-weight: 400;
font-size: 26rpx;
}
.transfer-detail-popup {
padding: 28rpx 32rpx 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
.td-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.td-scroll {
max-height: 52vh;
}
.td-row {
display: flex;
margin-bottom: 16rpx;
font-size: 28rpx;
line-height: 1.45;
}
.td-k {
color: #888;
width: 160rpx;
flex-shrink: 0;
}
.td-v {
flex: 1;
color: #333;
word-break: break-all;
}
.td-qa {
margin-top: 8rpx;
padding: 16rpx;
background: #fafafa;
border-radius: 12rpx;
border: 1rpx solid #eee;
}
.td-qa-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 12rpx;
}
.td-qa-item {
margin-bottom: 12rpx;
padding: 12rpx;
background: #fff;
border-radius: 8rpx;
}
.td-qa-q {
display: block;
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.td-qa-a {
font-size: 24rpx;
color: $primary-color;
}
.td-foot {
margin-top: 24rpx;
display: flex;
justify-content: center;
}
.td-foot--row {
flex-wrap: wrap;
justify-content: flex-end;
gap: 16rpx;
}
.chat-messages {
flex: 1;
min-height: 0;
padding-bottom: 8rpx;
}
.message-list-container {
padding: 16rpx 0 120rpx;
}
.message-wrapper {
width: 100%;
}
.chat-bottom-anchor {
height: 1px;
width: 100%;
}
.empty-state--top {
padding: 12rpx 0 8rpx;
}
.empty-state--top .empty-text {
font-size: 24rpx;
color: #bbb;
}
.time-divider {
text-align: center;
margin: 16rpx 0;
}
.time-text {
font-size: 22rpx;
color: #999;
}
.loading-spinner {
text-align: center;
padding: 16rpx;
}
.spinner-text {
font-size: 24rpx;
color: #999;
}
.empty-state {
padding: 80rpx;
text-align: center;
}
.empty-text {
color: #999;
font-size: 28rpx;
}
.chat-footer {
position: relative;
flex-shrink: 0;
background: #fff;
padding-bottom: env(safe-area-inset-bottom);
}
.consultation-ended-notice {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx;
color: #999;
font-size: 26rpx;
}
.input-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
border-top: 1rpx solid #eee;
}
.icon-btn {
padding: 12rpx;
}
.input-field-wrapper {
flex: 1;
margin: 0 16rpx;
}
.main-input {
height: 72rpx;
padding: 0 24rpx;
background: #f5f5f5;
border-radius: 36rpx;
font-size: 28rpx;
}
.voice-btn {
text-align: center;
height: 72rpx;
line-height: 72rpx;
background: #f5f5f5;
border-radius: 36rpx;
font-size: 28rpx;
color: #666;
}
.voice-btn.recording {
background: #ffe0e0;
color: #c00;
}
.right-action {
min-width: 100rpx;
display: flex;
justify-content: flex-end;
}
.send-btn {
padding: 12rpx 28rpx;
background: $primary-color;
color: #fff;
border-radius: 32rpx;
font-size: 28rpx;
margin-right: 8rpx;
}
.plus-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.plus-char {
font-size: 48rpx;
font-weight: 300;
color: #666;
line-height: 1;
}
.plus-char--open {
color: $primary-color;
}
.tool-panel-wrap {
background: #fff;
border-radius: 28rpx 28rpx 0 0;
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.06);
overflow: hidden;
border-top: 1rpx solid #eef2f0;
}
.tool-panel-swiper {
height: 360rpx;
width: 100%;
}
.tool-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 28rpx 12rpx 20rpx;
box-sizing: border-box;
}
.tool-cell {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 12rpx 0 20rpx;
box-sizing: border-box;
}
.tool-cell-icon {
width: 100rpx;
height: 100rpx;
border-radius: 24rpx;
background: linear-gradient(145deg, #e8f6f4 0%, #f0faf8 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(106, 205, 187, 0.15);
}
.tool-cell-label {
margin-top: 12rpx;
font-size: 24rpx;
color: #333;
text-align: center;
line-height: 1.25;
max-width: 95%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.tool-cell.is-disabled .tool-cell-icon {
opacity: 0.35;
}
.tool-cell.is-disabled .tool-cell-label {
color: #bbb;
}
.tool-cell--placeholder {
opacity: 0;
pointer-events: none;
}
.orders-popup {
padding: 28rpx 32rpx 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
.orders-popup-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.orders-popup-scroll {
max-height: 56vh;
}
.orders-block {
margin-bottom: 24rpx;
}
.orders-row {
display: flex;
margin-bottom: 14rpx;
font-size: 28rpx;
line-height: 1.4;
}
.orders-k {
color: #888;
width: 160rpx;
flex-shrink: 0;
}
.orders-v {
flex: 1;
color: #333;
word-break: break-all;
}
.orders-v.order-font {
font-family: Courier, monospace;
font-size: 24rpx;
}
.orders-v.price {
color: #ef4444;
font-weight: 600;
}
.orders-sub {
padding: 12rpx 16rpx;
background: #f8fafc;
border-radius: 10rpx;
margin-top: 10rpx;
font-size: 24rpx;
color: #666;
}
.orders-sub-label {
color: #999;
margin-right: 8rpx;
}
.orders-sub-no {
color: #333;
}
.orders-hint {
font-size: 26rpx;
color: #999;
padding: 12rpx 0;
}
.orders-section-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin: 20rpx 0 12rpx;
}
.orders-rx-row {
display: flex;
align-items: center;
padding: 20rpx 16rpx;
margin-bottom: 10rpx;
background: #f8fafc;
border-radius: 12rpx;
border: 1rpx solid #eef2f7;
}
.orders-rx-no {
flex: 1;
font-size: 28rpx;
color: #333;
word-break: break-all;
}
.orders-rx-price {
font-size: 26rpx;
color: #ef4444;
font-weight: 600;
margin-right: 12rpx;
}
.orders-rx-link {
font-size: 24rpx;
color: #6acdbb;
flex-shrink: 0;
}
.orders-popup-foot {
margin-top: 24rpx;
display: flex;
justify-content: center;
}
.orders-popup-close {
padding: 16rpx 48rpx;
font-size: 28rpx;
color: #6acdbb;
font-weight: 500;
}
.quick-reply-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.quick-reply-sheet {
width: 100%;
max-height: 60vh;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.quick-reply-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #eee;
}
.quick-reply-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.quick-reply-close {
font-size: 28rpx;
color: $primary-color;
}
.quick-reply-scroll {
max-height: 50vh;
}
.quick-reply-row {
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.quick-reply-text {
font-size: 28rpx;
color: #333;
line-height: 1.5;
}
.quick-reply-empty {
padding: 48rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
.recording-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.recording-box {
background: #fff;
padding: 48rpx 64rpx;
border-radius: 16rpx;
text-align: center;
}
.timer {
font-size: 36rpx;
font-weight: 600;
}
.tip {
display: block;
margin-top: 16rpx;
color: #666;
font-size: 26rpx;
}
</style>