Files
2026-06-11 17:48:46 +08:00

1444 lines
37 KiB
Vue
Raw Permalink 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-page">
<view class="chat-shell">
<view class="top-nav">
<button class="icon-button" aria-label="设置" @click="openSettings">
<view class="settings-icon"></view>
</button>
<button class="icon-button home-button" aria-label="首页" @click="goHome">
<view class="home-icon"></view>
</button>
<view class="nav-spacer"></view>
<button class="icon-button" aria-label="个人中心" @click="openProfile">
<view class="account-icon"></view>
</button>
</view>
<view class="case-header">
<view class="case-title-row">
<text class="case-heading">患者{{ session.patient.name }} ({{ complaintShort }})</text>
<button class="finish-button" :disabled="completingInquiry" @click="handleCompleteInquiry">
<view class="check-icon"></view>
<text>{{ completingInquiry ? '提交中' : '完成采集' }}</text>
</button>
</view>
<view class="patient-meta">
<text>姓名{{ session.patient.name }}</text>
<text>性别{{ session.patient.gender }}</text>
<text>年龄{{ session.patient.age }}</text>
<text>科室{{ session.patient.department }}</text>
</view>
</view>
<view class="stage-bar">
<view class="stage-line"></view>
<view class="stage-line-active"></view>
<view
v-for="stage in session.stages"
:key="stage.key"
class="stage-item"
:class="{ active: stage.active }"
>
<view class="stage-dot">
<view class="stage-icon" :class="`stage-icon-${stage.key}`"></view>
</view>
<text>{{ stage.label }}</text>
</view>
</view>
<scroll-view class="chat-body" scroll-y :scroll-top="scrollTop">
<view class="message-list">
<view
v-for="message in session.messages"
:key="message.id"
class="message-row"
:class="[`role-${message.role}`]"
>
<view v-if="message.role === 'patient'" class="avatar patient-avatar">
<text>{{ session.patient.name.slice(0, 1) }}</text>
</view>
<view class="message-bubble" :class="`${message.role}-bubble`">
<text class="message-content">"{{ message.content }}"</text>
<view class="message-label" :class="{ mentor: message.role === 'mentor' }">
<view v-if="message.role === 'mentor'" class="star-icon"></view>
<text>{{ message.label }}</text>
</view>
</view>
<view v-if="message.role === 'doctor'" class="avatar doctor-avatar">
<view class="person-icon"></view>
</view>
</view>
</view>
</scroll-view>
<view class="mentor-float" :class="{ loading: hinting }" @click.stop="handleMentorHint" @tap.stop="handleMentorHint">
<text class="mentor-badge">王主任</text>
<view class="mentor-avatar" @click.stop="handleMentorHint" @tap.stop="handleMentorHint">
<image src="/static/config-doctor.png" mode="aspectFill"></image>
</view>
</view>
<view class="input-panel">
<scroll-view class="quick-actions" scroll-x>
<view class="quick-row">
<button class="quick-chip" @click="openPhysicalPanel">体格检查</button>
<button class="quick-chip" @click="openExamPanel">辅助检查</button>
</view>
</scroll-view>
<view class="input-row">
<input
class="chat-input"
v-model="draft"
type="text"
placeholder="输入你对患者的提问..."
placeholder-class="input-placeholder"
@confirm="handleSend"
/>
<button class="send-button" :disabled="sending" @click="handleSend">
<view class="send-icon"></view>
</button>
</view>
</view>
</view>
<view v-if="physicalPanelVisible" class="exam-mask" @click="physicalPanelVisible = false">
<view class="physical-panel" @click.stop>
<view class="exam-header">
<text class="exam-title">选择体格检查</text>
<button class="exam-close" aria-label="关闭" @click="physicalPanelVisible = false">
<view class="close-icon"></view>
</button>
</view>
<view class="exam-list">
<view v-if="physicalExamsLoading" class="exam-empty">
<text>体格检查加载中...</text>
</view>
<view v-else-if="physicalExams.length === 0" class="exam-empty">
<text>暂无可选体格检查</text>
</view>
<block v-else>
<button
v-for="exam in physicalExams"
:key="exam.item_code"
class="exam-item"
:disabled="examOrdering"
@click="selectPhysicalExam(exam)"
>
<text class="exam-name">{{ exam.item_name }}</text>
<view class="chevron-icon"></view>
</button>
</block>
</view>
</view>
</view>
<view v-if="examPanelVisible" class="exam-mask" @click="examPanelVisible = false">
<view class="exam-panel" @click.stop>
<view class="exam-header">
<text class="exam-title">选择辅助检查</text>
<button class="exam-close" aria-label="关闭" @click="examPanelVisible = false">
<view class="close-icon"></view>
</button>
</view>
<view class="exam-list">
<view v-if="auxiliaryExamsLoading" class="exam-empty">
<text>辅助检查加载中...</text>
</view>
<view v-else-if="auxiliaryExams.length === 0" class="exam-empty">
<text>暂无可选辅助检查</text>
</view>
<block v-else>
<button
v-for="exam in auxiliaryExams"
:key="exam.item_code"
class="exam-item"
:disabled="examOrdering"
@click="selectAuxiliaryExam(exam)"
>
<text class="exam-name">{{ exam.item_name }}</text>
<view class="chevron-icon"></view>
</button>
</block>
</view>
</view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { readStoredClinicalCase, type ClinicalCase } from '../../api/cases'
import { createMockChatSession, sendMockChatMessage, type ChatMessage, type ChatSession } from '../../api/chat'
import {
fetchAuxiliaryExamItems,
fetchPhysicalExamItems,
orderAuxiliaryExamResult,
orderPhysicalExamResult,
type ExamItem,
type ExamResult
} from '../../api/exams'
import { createHomeNavigator, createProfileOpener, createSettingsOpener } from '../../api/navigation'
import {
completeInquiry,
readStoredTrainingScenario,
streamSessionChat,
streamSessionHint,
updateStoredSessionStatus
} from '../../api/session'
const props = defineProps<{
caseItem?: ClinicalCase | null
}>()
const emit = defineEmits<{
(event: 'open-settings'): void
(event: 'open-profile'): void
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const openSettings = createSettingsOpener(emit)
const goHome = createHomeNavigator(emit)
const session = reactive<ChatSession>({
patient: {
name: '陈先生',
gender: '男',
age: 60,
department: '心血管内科',
chiefComplaint: '持续胸痛3小时'
},
stages: [
{ key: 'history', label: '病史采集', active: true },
{ key: 'diagnosis', label: '初步诊断', active: false },
{ key: 'treatment', label: '治疗方案', active: false }
],
messages: []
})
const draft = ref('')
const sending = ref(false)
const hinting = ref(false)
const completingInquiry = ref(false)
const scrollTop = ref(0)
const toastMessage = ref('')
const toastVisible = ref(false)
const examPanelVisible = ref(false)
const physicalPanelVisible = ref(false)
const activeSessionId = ref<number | null>(null)
const storedCase = ref<ClinicalCase | null>(null)
const physicalExams = ref<ExamItem[]>([])
const auxiliaryExams = ref<ExamItem[]>([])
const physicalExamsLoading = ref(false)
const auxiliaryExamsLoading = ref(false)
const examOrdering = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let activeStreamController: AbortController | null = null
let activeHintController: AbortController | null = null
const activeCase = computed(() => props.caseItem || storedCase.value)
const complaintShort = computed(() => {
if (session.patient.chiefComplaint.includes('胸痛')) return '胸痛'
return session.patient.chiefComplaint.slice(0, 6)
})
function loadSession() {
createMockChatSession(activeCase.value).then(result => {
Object.assign(session.patient, result.patient)
session.stages = result.stages
const scenario = readStoredTrainingScenario()
if (scenario?.session?.session_id) {
activeSessionId.value = scenario.session.session_id
session.messages = scenario.session.patient_opening ? [
{
id: `patient-opening-${scenario.session.session_id}`,
role: 'patient',
content: scenario.session.patient_opening,
label: '患者'
}
] : []
scrollToBottom()
void streamPatientReply('开始问诊')
return
}
session.messages = result.messages
scrollToBottom()
})
}
function sendQuickAction(content: string) {
draft.value = content
handleSend()
}
async function handleCompleteInquiry() {
if (completingInquiry.value) return
if (sending.value || hinting.value) {
showToast('请等待当前回复完成')
return
}
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('未找到当前会话,请先生成模拟场景')
return
}
completingInquiry.value = true
try {
const result = await completeInquiry(sessionId)
updateStoredSessionStatus(result.status)
uni.navigateTo({
url: '/pages/diagnosis/diagnosis'
})
} catch (error) {
showToast(error instanceof Error ? error.message : '完成采集失败')
} finally {
completingInquiry.value = false
}
}
async function openExamPanel() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('未找到当前会话,请先生成模拟场景')
return
}
physicalPanelVisible.value = false
examPanelVisible.value = true
if (auxiliaryExams.value.length > 0 || auxiliaryExamsLoading.value) return
auxiliaryExamsLoading.value = true
try {
auxiliaryExams.value = await fetchAuxiliaryExamItems(sessionId)
} catch (error) {
showToast(error instanceof Error ? error.message : '辅助检查列表加载失败')
} finally {
auxiliaryExamsLoading.value = false
}
}
async function openPhysicalPanel() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('未找到当前会话,请先生成模拟场景')
return
}
examPanelVisible.value = false
physicalPanelVisible.value = true
if (physicalExams.value.length > 0 || physicalExamsLoading.value) return
physicalExamsLoading.value = true
try {
physicalExams.value = await fetchPhysicalExamItems(sessionId)
} catch (error) {
showToast(error instanceof Error ? error.message : '体格检查列表加载失败')
} finally {
physicalExamsLoading.value = false
}
}
async function selectPhysicalExam(exam: ExamItem) {
const sessionId = activeSessionId.value
if (!sessionId || examOrdering.value) return
examOrdering.value = true
try {
const result = await orderPhysicalExamResult(sessionId, exam.item_code)
physicalPanelVisible.value = false
appendExamResultMessages('体格检查', result)
} catch (error) {
showToast(error instanceof Error ? error.message : '体格检查结果获取失败')
} finally {
examOrdering.value = false
}
}
async function selectAuxiliaryExam(exam: ExamItem) {
const sessionId = activeSessionId.value
if (!sessionId || examOrdering.value) return
examOrdering.value = true
try {
const result = await orderAuxiliaryExamResult(sessionId, exam.item_code)
examPanelVisible.value = false
appendExamResultMessages('辅助检查', result)
} catch (error) {
showToast(error instanceof Error ? error.message : '辅助检查结果获取失败')
} finally {
examOrdering.value = false
}
}
function appendExamResultMessages(kindLabel: string, result: ExamResult) {
const timestamp = Date.now()
const messages: ChatMessage[] = [
{
id: `doctor-exam-${timestamp}`,
role: 'doctor',
content: `选择${kindLabel}${result.item_name}`,
label: '我'
},
{
id: `mentor-exam-${timestamp + 1}`,
role: 'mentor',
content: formatExamResult(result),
label: 'AI助手'
}
]
session.messages.push(...messages)
scrollToBottom()
}
function formatExamResult(result: ExamResult) {
const flags = [
result.is_abnormal ? '异常' : '',
result.is_key ? '关键检查' : '',
result.already_ordered ? '已检查过' : ''
].filter(Boolean)
const suffix = flags.length ? `${flags.join('')}` : ''
return `${result.item_name}${suffix}${result.result_text || '暂无结果。'}`
}
function handleSend() {
const content = draft.value.trim()
if (!content || sending.value) return
draft.value = ''
if (activeSessionId.value) {
session.messages.push({
id: `doctor-${Date.now()}`,
role: 'doctor',
content,
label: '我'
})
scrollToBottom()
void streamPatientReply(content)
return
}
sending.value = true
sendMockChatMessage(content).then(messages => {
session.messages.push(...messages)
scrollToBottom()
}).finally(() => {
sending.value = false
})
}
async function streamPatientReply(message: string) {
const sessionId = activeSessionId.value
if (!sessionId) return
sending.value = true
activeStreamController?.abort()
activeStreamController = new AbortController()
const streamedMessageIndex = session.messages.length
session.messages.push({
id: `patient-stream-${Date.now()}`,
role: 'patient',
content: '',
label: '患者'
})
scrollToBottom()
try {
await streamSessionChat(sessionId, message, {
onDelta(delta) {
session.messages[streamedMessageIndex].content += delta
scrollToBottom()
}
}, activeStreamController.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!session.messages[streamedMessageIndex].content.trim()) {
session.messages[streamedMessageIndex].content = 'AI 病人回复失败,请重试。'
}
showToast(error instanceof Error ? error.message : 'AI 流式回复异常')
} finally {
sending.value = false
activeStreamController = null
scrollToBottom()
}
}
function findLastUserMessage() {
const lastDoctorMessage = [...session.messages].reverse().find(message => message.role === 'doctor')
return lastDoctorMessage?.content || '开始问诊'
}
async function handleMentorHint() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('请先生成模拟场景')
return
}
if (hinting.value) return
hinting.value = true
activeHintController?.abort()
activeHintController = new AbortController()
const streamedMessageIndex = session.messages.length
session.messages.push({
id: `mentor-hint-${Date.now()}`,
role: 'mentor',
content: '王主任正在生成提示...',
label: '王主任'
})
scrollToBottom()
try {
let receivedFirstDelta = false
await streamSessionHint(sessionId, findLastUserMessage(), {
onDelta(delta) {
if (!receivedFirstDelta) {
session.messages[streamedMessageIndex].content = ''
receivedFirstDelta = true
}
session.messages[streamedMessageIndex].content += delta
scrollToBottom()
}
}, activeHintController.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
if (
!session.messages[streamedMessageIndex].content.trim()
|| session.messages[streamedMessageIndex].content === '王主任正在生成提示...'
) {
session.messages[streamedMessageIndex].content = '练习提示生成失败,请稍后重试。'
}
showToast(error instanceof Error ? error.message : '练习提示生成失败,请稍后重试')
} finally {
hinting.value = false
activeHintController = null
scrollToBottom()
}
}
function scrollToBottom() {
setTimeout(() => {
scrollTop.value += 1000
}, 60)
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onMounted(() => {
storedCase.value = readStoredClinicalCase()
loadSession()
})
onUnmounted(() => {
activeStreamController?.abort()
activeHintController?.abort()
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<style scoped>
page {
min-height: 100%;
background: #f9f9ff;
}
.chat-page {
min-height: 100vh;
background: #f9f9ff;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.chat-page view,
.chat-page text,
.chat-page button,
.chat-page input,
.chat-page scroll-view {
box-sizing: border-box;
}
.chat-shell {
position: relative;
height: 100vh;
overflow: hidden;
background: #f9f9ff;
display: flex;
flex-direction: column;
}
.top-nav {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 50;
box-sizing: border-box;
height: 56px;
padding: 0 20px;
border-bottom: 1px solid rgba(194, 198, 212, 0.3);
background: #ffffff;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
display: flex;
align-items: center;
}
.nav-spacer {
flex: 1;
}
.icon-button {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.home-button {
margin-left: 4px;
}
.icon-button::after,
.finish-button::after,
.quick-chip::after,
.send-button::after {
border: 0;
}
.icon-button:active {
background: rgba(25, 28, 33, 0.05);
}
.settings-icon,
.home-icon,
.account-icon,
.check-icon,
.person-icon,
.star-icon,
.send-icon {
background: #424752;
}
.settings-icon {
width: 22px;
height: 22px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19.43%2012.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.37-.31-.6-.22l-2.49%201c-.52-.4-1.08-.73-1.69-.98L14.5%202.42C14.47%202.18%2014.25%202%2014%202h-4c-.25%200-.46.18-.5.42l-.38%202.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.48%200-.6.22l-2%203.46c-.13.22-.07.49.12.64l2.11%201.65c-.04.32-.08.65-.08.98s.03.66.08.98l-2.11%201.65c-.19.15-.24.42-.12.64l2%203.46c.12.22.37.31.6.22l2.49-1c.52.4%201.08.73%201.69.98l.38%202.65c.04.24.25.42.5.42h4c.25%200%20.46-.18.5-.42l.38-2.65c.61-.25%201.17-.58%201.69-.98l2.49%201c.23.08.48%200%20.6-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12%2015.5A3.5%203.5%200%201%201%2012%208a3.5%203.5%200%200%201%200%207.5z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19.43%2012.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.37-.31-.6-.22l-2.49%201c-.52-.4-1.08-.73-1.69-.98L14.5%202.42C14.47%202.18%2014.25%202%2014%202h-4c-.25%200-.46.18-.5.42l-.38%202.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.48%200-.6.22l-2%203.46c-.13.22-.07.49.12.64l2.11%201.65c-.04.32-.08.65-.08.98s.03.66.08.98l-2.11%201.65c-.19.15-.24.42-.12.64l2%203.46c.12.22.37.31.6.22l2.49-1c.52.4%201.08.73%201.69.98l.38%202.65c.04.24.25.42.5.42h4c.25%200%20.46-.18.5-.42l.38-2.65c.61-.25%201.17-.58%201.69-.98l2.49%201c.23.08.48%200%20.6-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12%2015.5A3.5%203.5%200%201%201%2012%208a3.5%203.5%200%200%201%200%207.5z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.home-icon {
width: 23px;
height: 23px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M10%2020v-6h4v6h5v-8h3L12%203%202%2012h3v8h5z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M10%2020v-6h4v6h5v-8h3L12%203%202%2012h3v8h5z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.account-icon {
width: 24px;
height: 24px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.case-header {
position: fixed;
left: 0;
right: 0;
top: 56px;
z-index: 40;
box-sizing: border-box;
height: 100px;
padding: 16px 20px;
background: #f9f9ff;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
}
.case-title-row {
position: relative;
height: 32px;
display: block;
}
.case-heading {
display: block;
box-sizing: border-box;
width: 100%;
padding-right: 116px;
color: #191c21;
font-size: 18px;
line-height: 28px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finish-button {
position: absolute;
right: 0;
top: 0;
z-index: 2;
width: 112px;
height: 32px;
min-height: 32px;
margin: 0;
padding: 0 14px;
border: 0;
border-radius: 999px;
background: #00478d;
box-shadow: 0 4px 10px rgba(0, 71, 141, 0.22);
color: #ffffff;
font-size: 14px;
line-height: 20px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.finish-button text {
white-space: nowrap;
}
.check-icon {
width: 18px;
height: 18px;
background: #ffffff;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm-1.2%2014.4-4-4L8.2%2011l2.6%202.6L16.8%207.6%2018.2%209l-7.4%207.4z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm-1.2%2014.4-4-4L8.2%2011l2.6%202.6L16.8%207.6%2018.2%209l-7.4%207.4z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.patient-meta {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(194, 198, 212, 0.3);
display: flex;
flex-wrap: wrap;
column-gap: 24px;
row-gap: 4px;
color: #424752;
font-size: 13px;
line-height: 20px;
}
.stage-bar {
position: fixed;
left: 0;
right: 0;
top: 156px;
z-index: 30;
box-sizing: border-box;
height: 72px;
padding: 8px 24px;
border-bottom: 1px solid rgba(194, 198, 212, 0.3);
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
}
.stage-line,
.stage-line-active {
position: absolute;
left: 24px;
right: 24px;
top: 28px;
height: 2px;
background: #e1e2ea;
}
.stage-line-active {
right: auto;
width: 10%;
background: #00478d;
}
.stage-item {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #c2c6d4;
font-size: 11px;
line-height: 16px;
}
.stage-item.active {
color: #00478d;
font-weight: 700;
}
.stage-dot {
width: 24px;
height: 24px;
border-radius: 50%;
background: #e1e2ea;
display: flex;
align-items: center;
justify-content: center;
}
.stage-item.active .stage-dot {
background: #00478d;
box-shadow: 0 0 0 4px rgba(200, 218, 255, 0.5);
}
.stage-icon {
width: 14px;
height: 14px;
background: currentColor;
}
.stage-icon-history {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19%203H5a2%202%200%200%200-2%202v14a2%202%200%200%200%202%202h14a2%202%200%200%200%202-2V5a2%202%200%200%200-2-2zm-7%203a3%203%200%201%201%200%206%203%203%200%200%201%200-6zm6%2012H6v-1.2c0-2%204-3.1%206-3.1s6%201.1%206%203.1V18z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19%203H5a2%202%200%200%200-2%202v14a2%202%200%200%200%202%202h14a2%202%200%200%200%202-2V5a2%202%200%200%200-2-2zm-7%203a3%203%200%201%201%200%206%203%203%200%200%201%200-6zm6%2012H6v-1.2c0-2%204-3.1%206-3.1s6%201.1%206%203.1V18z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.stage-icon-diagnosis {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19%203h-1V1h-2v2H8V1H6v2H5a2%202%200%200%200-2%202v14a2%202%200%200%200%202%202h14a2%202%200%200%200%202-2V5a2%202%200%200%200-2-2zM8%207h8v2H8V7zm0%204h8v2H8v-2zm0%204h5v2H8v-2z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19%203h-1V1h-2v2H8V1H6v2H5a2%202%200%200%200-2%202v14a2%202%200%200%200%202%202h14a2%202%200%200%200%202-2V5a2%202%200%200%200-2-2zM8%207h8v2H8V7zm0%204h8v2H8v-2zm0%204h5v2H8v-2z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.stage-icon-treatment {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m4.22%2019.78-.01-.01a5.5%205.5%200%200%201%200-7.78L12%204.22a5.5%205.5%200%200%201%207.78%207.78L12%2019.78a5.5%205.5%200%200%201-7.78%200zM13.41%205.64%2010%209.05%2014.95%2014%2018.36%2010.59a3.5%203.5%200%200%200-4.95-4.95zM5.64%2013.41a3.5%203.5%200%200%200%204.95%204.95L14%2014.95%209.05%2010%205.64%2013.41z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m4.22%2019.78-.01-.01a5.5%205.5%200%200%201%200-7.78L12%204.22a5.5%205.5%200%200%201%207.78%207.78L12%2019.78a5.5%205.5%200%200%201-7.78%200zM13.41%205.64%2010%209.05%2014.95%2014%2018.36%2010.59a3.5%203.5%200%200%200-4.95-4.95zM5.64%2013.41a3.5%203.5%200%200%200%204.95%204.95L14%2014.95%209.05%2010%205.64%2013.41z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.stage-item.active .stage-icon {
background: #ffffff;
}
.chat-body {
box-sizing: border-box;
height: 100vh;
padding: 244px 20px 188px;
}
.message-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.message-row {
display: flex;
align-items: flex-start;
gap: 16px;
max-width: 85%;
}
.role-mentor {
align-self: flex-end;
}
.role-doctor {
align-self: flex-end;
flex-direction: row-reverse;
}
.avatar {
flex: 0 0 auto;
width: 40px;
height: 40px;
border: 1px solid #c2c6d4;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.patient-avatar {
background: linear-gradient(135deg, #ffb691, #9f4300);
color: #ffffff;
font-size: 18px;
font-weight: 700;
}
.doctor-avatar {
background: #7af1fc;
}
.person-icon {
width: 24px;
height: 24px;
background: #006e75;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.message-bubble {
position: relative;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
}
.message-bubble::after {
content: '';
position: absolute;
top: 12px;
width: 14px;
height: 14px;
transform: rotate(45deg);
}
.patient-bubble {
border: 1px solid #c2c6d4;
background: #f9f9ff;
}
.patient-bubble::after {
left: -8px;
border-left: 1px solid #c2c6d4;
border-bottom: 1px solid #c2c6d4;
background: #f9f9ff;
}
.mentor-bubble {
border: 1px solid rgba(0, 71, 141, 0.2);
background: #e7e8f0;
}
.mentor-bubble::after {
right: -8px;
border-right: 1px solid rgba(0, 71, 141, 0.2);
border-top: 1px solid rgba(0, 71, 141, 0.2);
background: #e7e8f0;
}
.doctor-bubble {
border: 1px solid rgba(0, 71, 141, 0.1);
background: #005eb8;
}
.doctor-bubble::after {
right: -8px;
background: #005eb8;
}
.message-content {
color: #191c21;
font-size: 16px;
line-height: 24px;
}
.mentor-bubble .message-content {
color: #00478d;
font-weight: 600;
font-style: italic;
}
.doctor-bubble .message-content {
color: #c8daff;
}
.message-label {
margin-top: 8px;
color: #727783;
font-size: 12px;
line-height: 16px;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 4px;
}
.message-label.mentor {
color: #00478d;
font-weight: 700;
}
.doctor-bubble .message-label {
justify-content: flex-end;
color: rgba(200, 218, 255, 0.72);
}
.star-icon {
width: 16px;
height: 16px;
background: #00478d;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m12%202%202.7%206.6%207.1.6-5.4%204.6%201.7%206.9L12%2017l-6.1%203.7%201.7-6.9-5.4-4.6%207.1-.6L12%202z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m12%202%202.7%206.6%207.1.6-5.4%204.6%201.7%206.9L12%2017l-6.1%203.7%201.7-6.9-5.4-4.6%207.1-.6L12%202z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.mentor-float {
position: fixed;
right: 20px;
bottom: 140px;
z-index: 45;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
pointer-events: auto;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.mentor-float:active {
transform: scale(0.96);
}
.mentor-float.loading {
opacity: 0.72;
pointer-events: none;
}
.mentor-badge {
margin-bottom: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #00478d;
box-shadow: 0 3px 8px rgba(0, 71, 141, 0.22);
color: #ffffff;
font-size: 12px;
line-height: 16px;
font-weight: 700;
animation: pulse-soft 1.6s ease-in-out infinite;
}
.mentor-avatar {
width: 96px;
height: 96px;
border: 2px solid #00478d;
border-radius: 50%;
box-shadow: 0 8px 18px rgba(25, 28, 33, 0.18);
overflow: hidden;
background: #ffffff;
animation: breathe 3s ease-in-out infinite;
}
.mentor-avatar image {
width: 100%;
height: 100%;
}
.input-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
box-sizing: border-box;
padding: 12px;
border-top: 1px solid #c2c6d4;
background: #f9f9ff;
display: flex;
flex-direction: column;
gap: 16px;
}
.quick-actions {
width: 100%;
white-space: nowrap;
}
.quick-row {
display: flex;
gap: 8px;
}
.quick-chip {
flex: 0 0 auto;
padding: 8px 16px;
border: 1px solid #006970;
border-radius: 999px;
background: #ffffff;
color: #006970;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.input-row {
min-height: 48px;
padding: 4px;
border: 1px solid #c2c6d4;
border-radius: 12px;
background: #ecedf6;
display: flex;
align-items: center;
gap: 8px;
}
.chat-input {
flex: 1;
min-width: 0;
height: 40px;
padding: 0 8px;
border: 0;
background: transparent;
color: #191c21;
font-size: 16px;
line-height: 24px;
}
.input-placeholder {
color: #c2c6d4;
}
.send-button {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 8px;
background: #00478d;
display: flex;
align-items: center;
justify-content: center;
}
.send-button:active {
transform: scale(0.95);
}
.send-icon {
width: 22px;
height: 22px;
background: #ffffff;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M2%2021 23%2012 2%203v7l15%202-15%202v7z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M2%2021 23%2012 2%203v7l15%202-15%202v7z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.exam-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 120;
background: rgba(25, 28, 33, 0.36);
display: flex;
align-items: flex-end;
justify-content: center;
}
.exam-panel {
width: 100%;
max-width: 448px;
max-height: 62vh;
border-radius: 20px 20px 0 0;
background: #ffffff;
box-shadow: 0 -12px 30px rgba(25, 28, 33, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.physical-panel {
width: 100%;
max-width: 448px;
max-height: 78vh;
border-radius: 20px 20px 0 0;
background: #ffffff;
box-shadow: 0 -12px 30px rgba(25, 28, 33, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.exam-header {
box-sizing: border-box;
flex: 0 0 auto;
height: 64px;
padding: 0 20px;
border-bottom: 1px solid #e7e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.exam-title {
color: #191c21;
font-size: 20px;
line-height: 28px;
font-weight: 700;
}
.exam-close {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.exam-close::after,
.exam-item::after {
border: 0;
}
.close-icon {
position: relative;
width: 24px;
height: 24px;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
left: 3px;
top: 11px;
width: 20px;
height: 2px;
border-radius: 999px;
background: #2e3037;
}
.close-icon::before {
transform: rotate(45deg);
}
.close-icon::after {
transform: rotate(-45deg);
}
.exam-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.exam-item {
box-sizing: border-box;
width: 100%;
height: 76px;
padding: 0 20px;
border-bottom: 1px solid #e7e8f0;
border-radius: 0;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
}
.exam-item:active {
background: #f2f3fb;
}
.exam-empty {
min-height: 120px;
padding: 24px;
border: 1px dashed rgba(194, 198, 212, 0.7);
border-radius: 12px;
background: rgba(249, 249, 255, 0.75);
color: rgba(66, 71, 82, 0.78);
font-size: 14px;
line-height: 22px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.exam-name {
color: #191c21;
font-size: 18px;
line-height: 26px;
font-weight: 500;
}
.chevron-icon {
width: 13px;
height: 13px;
border-top: 2px solid #191c21;
border-right: 2px solid #191c21;
transform: rotate(45deg);
}
.physical-form {
flex: 1;
min-height: 0;
padding: 16px 20px;
background: #f9f9ff;
}
.vital-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-field {
position: relative;
box-sizing: border-box;
min-height: 72px;
padding: 10px 12px;
border: 1px solid #c2c6d4;
border-radius: 12px;
background: #ffffff;
display: flex;
align-items: flex-end;
}
.form-field.full,
.form-field.textarea-field {
margin-top: 12px;
}
.field-label {
position: absolute;
left: 12px;
top: 8px;
color: #424752;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.field-input {
flex: 1;
min-width: 0;
height: 32px;
padding: 12px 4px 0 0;
border: 0;
background: transparent;
color: #191c21;
font-size: 16px;
line-height: 24px;
}
.field-unit {
flex: 0 0 auto;
margin-left: 4px;
color: #727783;
font-size: 12px;
line-height: 24px;
font-weight: 600;
}
.field-placeholder {
color: rgba(114, 119, 131, 0.55);
}
.textarea-field {
min-height: 104px;
align-items: stretch;
}
.field-textarea {
width: 100%;
min-height: 76px;
margin-top: 18px;
padding: 0;
border: 0;
background: transparent;
color: #191c21;
font-size: 15px;
line-height: 22px;
}
.physical-actions {
flex: 0 0 auto;
padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #e7e8f0;
background: #ffffff;
display: flex;
gap: 12px;
}
.physical-cancel,
.physical-submit {
flex: 1;
height: 48px;
border-radius: 12px;
font-size: 16px;
line-height: 48px;
font-weight: 700;
}
.physical-cancel::after,
.physical-submit::after {
border: 0;
}
.physical-cancel {
border: 1px solid rgba(0, 71, 141, 0.22);
background: #ffffff;
color: #00478d;
}
.physical-submit {
background: #00478d;
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 71, 141, 0.18);
}
.toast {
position: fixed;
left: 50%;
bottom: 148px;
z-index: 100;
max-width: 320px;
padding: 12px 24px;
border-radius: 999px;
background: #2e3037;
color: #eff0f8;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 16px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes breathe {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-4px) scale(1.02);
}
}
@keyframes pulse-soft {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.72;
}
}
</style>