Files
xk-client-wx/subPackages/common/components/AssistantConsultationShell.vue

782 lines
20 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>
<!--
问诊助手通用壳协议抽屉消息流选项按钮等
follow-up / transfer 分包页使用放在 subPackages/common 以减小主包体积
-->
<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>
<!-- 协议抽屉v-if 控制挂载:show 控制显隐 chat 页一致 -->
<page-container
v-if="agreementPopupVisible"
:show="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 && answerMode === 'multi'"
class="action-footer multi-footer safe-area-inset-bottom"
>
<!-- 多选适用症点选 toggling确认后再提交其他可填自定义症状 -->
<view class="multi-options">
<button
v-for="(answer, index) in currentQuestionAnswers"
:key="index"
hover-class="none"
:class="['multi-option-btn', { selected: !!multiSelectedFlag[index] }]"
@click="toggleMultiAnswer(index)"
>
{{ answer }}
</button>
</view>
<input
v-if="multiHasOtherSelected"
class="multi-other-input"
v-model="multiOtherText"
placeholder="请填写具体症状"
:maxlength="100"
/>
<!-- 不用原生 disabled避免微信小程序卡死无法点击未达标时 toast -->
<button
class="action-btn yes-btn confirm-btn"
hover-class="none"
:class="{ disabled: !multiConfirmEnabled }"
@click="confirmMultiAnswer"
>
确认
</button>
</view>
<view
v-else-if="showQuestionButtons && currentQuestion && !isSubmitting && !showWaiting"
class="action-footer safe-area-inset-bottom"
>
<button
v-for="(answer, index) in currentQuestionAnswers"
:key="index"
hover-class="none"
:class="['action-btn', answerButtonClass(answer, index)]"
@click="emitSingleAnswer(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,
},
/**
* 答题模式single 点一下即提交multi 可多选后点确认
* 诊所复诊按药选适用症用 multi
*/
answerMode: {
type: String,
default: 'single',
},
},
data() {
return {
/** 多选已选下标列表(整表替换,微信小程序下比 object+$set 更稳) */
multiSelectedIndexes: [],
/** 选「其他」时的自定义症状文案 */
multiOtherText: '',
/** 防双击emit 后立刻锁,题目切换解锁 */
answeringLock: false,
};
},
watch: {
// 题目切换时清空多选,避免上一题选中态残留
currentQuestion() {
this.multiSelectedIndexes = [];
this.multiOtherText = '';
this.answeringLock = false;
},
answerMode() {
this.multiSelectedIndexes = [];
this.multiOtherText = '';
this.answeringLock = false;
},
},
computed: {
agreementPopupVisible: {
get() {
return this.showAgreement
},
set(val) {
this.$emit('update:showAgreement', val)
},
},
/** 供模板判断选中,避免 :class 里写 indexOf */
multiSelectedFlag() {
const map = {};
const list = this.multiSelectedIndexes || [];
for (let i = 0; i < list.length; i++) {
map[list[i]] = true;
}
return map;
},
multiSelectedCount() {
return (this.multiSelectedIndexes || []).length;
},
/** 是否选中了「其他」选项 */
multiHasOtherSelected() {
const answers = this.currentQuestionAnswers || [];
const list = this.multiSelectedIndexes || [];
for (let i = 0; i < list.length; i++) {
const idx = list[i];
if (answers[idx] === '其他') {
return true;
}
}
return false;
},
/** 多选确认是否可提交:至少选一项;选了其他则自定义文案非空 */
multiConfirmEnabled() {
if (this.multiSelectedCount === 0) return false;
if (this.multiHasOtherSelected) {
return !!(this.multiOtherText && String(this.multiOtherText).trim());
}
return true;
},
},
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'
},
/** 单选:点一下即提交,防双击重复 emit */
emitSingleAnswer(answer) {
if (this.answeringLock || this.isSubmitting) return;
this.answeringLock = true;
this.$emit('answer', answer);
},
/** 多选:切换某个适用症的选中状态 */
toggleMultiAnswer(index) {
if (this.answeringLock || this.isSubmitting) return;
const list = this.multiSelectedIndexes || [];
const pos = list.indexOf(index);
if (pos === -1) {
this.multiSelectedIndexes = list.concat([index]);
} else {
this.multiSelectedIndexes = list.filter((i) => i !== index);
if (this.currentQuestionAnswers[index] === '其他') {
this.multiOtherText = '';
}
}
},
/**
* 多选确认:选中标签用顿号拼接;「其他」替换为自定义填写内容
*/
confirmMultiAnswer() {
if (this.answeringLock || this.isSubmitting) return;
if (!this.multiConfirmEnabled) {
if (this.multiSelectedCount === 0) {
uni.showToast({ title: '请至少选择一项', icon: 'none' });
} else if (this.multiHasOtherSelected) {
uni.showToast({ title: '请填写具体症状', icon: 'none' });
}
return;
}
const indexes = (this.multiSelectedIndexes || []).slice().sort((a, b) => a - b);
if (!indexes.length) return;
const otherText = (this.multiOtherText || '').trim();
const labels = indexes
.map((i) => {
const label = this.currentQuestionAnswers[i];
if (label === '其他') return otherText;
return label;
})
.filter(Boolean);
if (!labels.length) return;
this.answeringLock = true;
this.$emit('answer', labels.join('、'));
this.multiSelectedIndexes = [];
this.multiOtherText = '';
},
},
}
</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;
min-height: 88rpx;
height: auto;
line-height: 1.35;
padding: 20rpx 16rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
white-space: normal;
/* 覆盖微信 button 默认白底/按下灰底,避免按钮组背景错乱 */
background-color: #0a84ff !important;
color: #fff !important;
&::after {
border: none;
}
&.yes-btn {
background-color: #0a84ff !important;
color: #fff !important;
}
&.no-btn {
background-color: #fff !important;
color: #333 !important;
border: 2rpx solid #ddd;
}
&.disabled,
&[disabled] {
opacity: 0.45;
}
}
&.multi-footer {
flex-direction: column;
gap: 16rpx;
.multi-options {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.multi-other-input {
width: 100%;
box-sizing: border-box;
padding: 16rpx 24rpx;
min-height: 72rpx;
border-radius: 12rpx;
background: #f8fafc;
font-size: 28rpx;
color: #333;
border: 1rpx solid #e2e8f0;
}
.multi-option-btn {
min-width: 140rpx;
padding: 0 28rpx;
height: 64rpx;
line-height: 64rpx;
border-radius: 32rpx;
font-size: 26rpx;
background-color: rgba(10, 132, 255, 0.08) !important;
color: #475569 !important;
border: 2rpx solid transparent;
&::after {
border: none;
}
&.selected {
background-color: #0a84ff !important;
color: #fff !important;
}
}
.confirm-btn {
width: 100%;
flex: none;
background-color: #0a84ff !important;
color: #fff !important;
}
}
}
.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>