Files
xk-client-wx/components/AssistantConsultationShell.vue
2026-07-07 08:50:17 +08:00

568 lines
13 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="consultation-container safe-area-inset-bottom">
<view class="consultation-header">
<view class="header-back" @click="handleHeaderBack">
<text class="iconfont icon-arrow-left"></text>
</view>
<view class="header-title">{{ headerTitle }}</view>
<view class="header-placeholder"></view>
</view>
<view class="notice-box" v-if="showNotice">
<view class="notice-text">
<text v-for="(part, index) in parsedNoticeParts" :key="index">
<text v-if="part.type === 'text'">{{ part.text }}</text>
<text v-else-if="part.type === 'agreement'" class="notice-link" @click="$emit('viewAgreement', part.agreementId)">
{{ part.text }}
</text>
</text>
</view>
</view>
<page-container
:if="agreementPopupVisible"
position="bottom"
round
overlay
custom-style="height:88%;"
@clickoverlay="closeAgreement"
@afterleave="closeAgreement"
>
<view class="agreement-popup">
<view class="agreement-header">
<text class="agreement-title">{{ currentAgreementName || '协议详情' }}</text>
<view class="agreement-close" @click="closeAgreement">×</view>
</view>
<scroll-view scroll-y class="agreement-content" :show-scrollbar="true">
<u-parse :html="agreementContent"></u-parse>
</scroll-view>
</view>
</page-container>
<scroll-view class="messages-scroll" scroll-y="true" :scroll-into-view="scrollIntoView" scroll-with-animation>
<view class="messages-container">
<view v-for="message in displayedMessages" :key="message.id">
<view v-if="message.type === 'welcome'" class="message-item other">
<image class="avatar" :src="assistantAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="assistant-label">{{ assistantName }}</view>
<view class="message-bubble">
<text>您好! 我是医生助理, 已收到您的用药申请:{{ prescriptionInfo }}, 为了帮助医生判断准确, 请按照您的真实情况回答以下问题</text>
</view>
</view>
</view>
<view v-else-if="message.type === 'question'" class="message-item other" :id="'question-' + message.questionId">
<image class="avatar" :src="assistantAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="assistant-label">{{ assistantName }}</view>
<view class="message-bubble">
<text>{{ message.content }}</text>
</view>
</view>
</view>
<view v-else-if="message.type === 'answer'" class="message-item mine">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="message-bubble">
<text>{{ message.content }}</text>
</view>
</view>
</view>
<view v-else-if="message.type === 'waiting'" class="message-item other">
<image class="avatar" :src="assistantAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="assistant-label">{{ assistantName }}</view>
<view class="message-bubble">
<text>请稍等, 正在为您转接医生进行复诊开方...</text>
</view>
</view>
</view>
<view v-else-if="message.type === 'hint'" class="message-item other">
<image class="avatar" :src="assistantAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="assistant-label">{{ assistantName }}</view>
<view class="message-bubble">
<text>{{ message.content }}</text>
</view>
</view>
</view>
</view>
<view v-if="isTyping" class="message-item other typing-indicator">
<image class="avatar" :src="assistantAvatar" mode="aspectFill"></image>
<view class="message-content">
<view class="assistant-label">{{ assistantName }}</view>
<view class="message-bubble typing-bubble">
<view class="typing-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
</view>
</view>
<view :id="bottomAnchorId" style="height: 1px;"></view>
</view>
</scroll-view>
<slot v-if="customFooterActive && $slots.footer" name="footer"></slot>
<view
v-else-if="showQuestionButtons && currentQuestion && !isSubmitting && !showWaiting"
class="action-footer safe-area-inset-bottom"
>
<button
v-for="(answer, index) in currentQuestionAnswers"
:key="index"
:class="['action-btn', answerButtonClass(answer, index)]"
@click="$emit('answer', answer)"
>
{{ answer }}
</button>
</view>
<view class="submitting-overlay" v-if="isSubmitting">
<view class="submitting-content">
<view class="loading-spinner"></view>
<text class="submitting-text">{{ submittingText }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'AssistantConsultationShell',
props: {
headerTitle: {
type: String,
default: '转诊咨询',
},
showNotice: {
type: Boolean,
default: true,
},
parsedNoticeParts: {
type: Array,
default: () => [],
},
showAgreement: {
type: Boolean,
default: false,
},
agreementContent: {
type: String,
default: '',
},
currentAgreementName: {
type: String,
default: '',
},
displayedMessages: {
type: Array,
default: () => [],
},
assistantAvatar: {
type: String,
default: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-client-wx/static/images/assistant-avatar.png',
},
assistantName: {
type: String,
default: '医生助理',
},
userAvatar: {
type: String,
default: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-client-wx/static/images/user-avatar.png',
},
prescriptionInfo: {
type: String,
default: '',
},
isTyping: {
type: Boolean,
default: false,
},
scrollIntoView: {
type: String,
default: '',
},
bottomAnchorId: {
type: String,
default: 'bottom-anchor',
},
/** 为 true 时展示底部「是/否」等选项(与转诊一致) */
showQuestionButtons: {
type: Boolean,
default: true,
},
currentQuestion: {
type: Object,
default: null,
},
currentQuestionAnswers: {
type: Array,
default: () => ['是', '否'],
},
isSubmitting: {
type: Boolean,
default: false,
},
showWaiting: {
type: Boolean,
default: false,
},
submittingText: {
type: String,
default: '正在提交...',
},
/** 为 true 时使用 #footer 插槽,避免父组件始终占位导致无法显示默认答题按钮 */
customFooterActive: {
type: Boolean,
default: false,
},
},
computed: {
agreementPopupVisible: {
get() {
return this.showAgreement
},
set(val) {
this.$emit('update:showAgreement', val)
},
},
},
methods: {
handleHeaderBack() {
if (this.showAgreement) {
this.closeAgreement();
return;
}
this.$emit('back');
},
closeAgreement() {
this.$emit('update:showAgreement', false);
},
answerButtonClass(answer, index) {
const answers = this.currentQuestionAnswers
const totalAnswers = answers.length
if (totalAnswers === 2) {
if (answer === '是') return 'yes-btn'
if (answer === '否') return 'no-btn'
}
if (index === 0) return 'yes-btn'
return 'no-btn'
},
},
}
</script>
<style lang="scss" scoped>
.consultation-container {
width: 100%;
height: 100vh;
background-color: #f8fafc;
display: flex;
flex-direction: column;
}
.consultation-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
position: relative;
flex-shrink: 0;
.header-back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: 32rpx;
color: #333;
}
}
.header-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.header-placeholder {
width: 60rpx;
}
}
.notice-box {
margin: 20rpx 30rpx;
padding: 24rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #666;
flex-shrink: 0;
.notice-text {
display: block;
word-break: break-all;
}
.notice-link {
color: #0a84ff;
text-decoration: underline;
}
}
.messages-scroll {
flex: 1;
min-height: 0;
overflow: hidden;
}
.messages-container {
padding: 30rpx;
min-height: 100%;
}
.message-item {
display: flex;
align-items: flex-start;
margin-bottom: 30rpx;
.avatar {
width: 72rpx;
height: 72rpx;
border-radius: 10rpx;
flex-shrink: 0;
background: #f2f2f2;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 70%;
margin-left: 16rpx;
.assistant-label {
font-size: 22rpx;
color: #999;
margin-bottom: 8rpx;
}
.message-bubble {
padding: 18rpx 24rpx;
font-size: 30rpx;
line-height: 1.5;
border-radius: 4rpx 12rpx 12rpx 12rpx;
background: #fff;
color: #333;
word-break: break-all;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
}
}
&.mine {
flex-direction: row-reverse;
.message-content {
margin-left: 0;
margin-right: 16rpx;
align-items: flex-end;
.message-bubble {
background: #0a84ff;
color: #fff;
border-radius: 12rpx 4rpx 12rpx 12rpx;
}
}
}
&.typing-indicator {
.typing-bubble {
padding: 18rpx 24rpx;
min-width: 80rpx;
}
.typing-dots {
display: flex;
align-items: center;
gap: 8rpx;
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #999;
animation: typing 1.4s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-10rpx);
opacity: 1;
}
}
.action-footer {
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
border-top: 1rpx solid #f0f0f0;
display: flex;
gap: 20rpx;
justify-content: space-between;
flex-shrink: 0;
.action-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 500;
border: none;
&::after {
border: none;
}
&.yes-btn {
background: #0a84ff;
color: #fff;
}
&.no-btn {
background: #fff;
color: #333;
border: 2rpx solid #ddd;
}
}
}
.submitting-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.submitting-content {
background: #fff;
padding: 60rpx 80rpx;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #0a84ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.submitting-text {
font-size: 28rpx;
color: #666;
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.agreement-popup {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.agreement-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 50rpx 20rpx;
border-bottom: 1rpx solid #e5e7eb;
background: #fff;
flex-shrink: 0;
}
.agreement-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #64748b;
line-height: 1;
}
.agreement-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
line-height: 1.5;
flex: 1;
min-width: 0;
}
.agreement-content {
flex: 1;
height: 0;
min-height: 200rpx;
padding: 30rpx 50rpx 40rpx;
box-sizing: border-box;
}
</style>