Files
xk-doctor-wx/subPackages/sub_online_reception/pages/chat.vue
2026-06-01 13:24:36 +08:00

1916 lines
54 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>
<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>