Files
2026-03-20 13:56:40 +08:00

941 lines
21 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="page">
<!-- 加载中 -->
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<!-- 医生详情 -->
<view v-else-if="doctor" class="main">
<!-- 医生头部 -->
<section class="hero-section">
<view class="hero-content">
<view class="avatar-wrap">
<image :src="doctor.avatar || defaultAvatar" class="avatar" mode="aspectFill"></image>
<view class="badge">{{ doctor.title || '主任医师' }}</view>
</view>
<view class="hero-info">
<text class="doctor-name">{{ doctor.name }}</text>
<text class="hospital">{{ doctor.hospital || '甄养堂医院' }}</text>
<view class="experience-row">
<text class="verified"></text>
<text class="experience">{{ doctor.experience || '24年临床经验' }}</text>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ doctor.rating || '4.9' }}</text>
<text class="stat-label">评分</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">100{{ formatPatientCount(doctor.patient_count) }}</text>
<text class="stat-label">患者数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item" v-if="doctor.license_no">
<text class="stat-value">执业证书编号</text>
<text class="stat-label">{{doctor.license_no}}</text>
</view>
</view>
</view>
</view>
</section>
<!-- 擅长领域 -->
<section class="about-section">
<view class="section-title-bar">
<text class="section-title">擅长领域</text>
</view>
<view class="about-content">
<text class="about-text">{{ doctor.about || '擅长运用传统中医理论与现代诊断相结合...' }}</text>
</view>
</section>
<!-- 医生资质 -->
<section class="qualification-section" v-if="qualificationImages.length > 0">
<text class="section-title-plain">医生资质</text>
<view class="qualification-grid">
<image
v-for="(img, idx) in qualificationImages"
:key="idx"
:src="img"
class="qualification-image"
mode="aspectFill"
@click="previewImage(img)"
/>
</view>
</section>
<!-- 专业领域 -->
<section class="expertise-section">
<text class="section-title-plain">专业领域</text>
<view class="expertise-grid">
<view
v-for="(item, idx) in (doctor.expertise || [])"
:key="idx"
class="expertise-card"
:class="{ 'expertise-wide': item.wide }"
>
<view class="expertise-icon" :class="'icon-' + (item.icon || 'default')">
<text class="icon-emoji">{{ getExpertiseIcon(item.icon) }}</text>
</view>
<view class="expertise-text">
<text class="expertise-title">{{ item.title }}</text>
<text class="expertise-desc">{{ item.desc }}</text>
</view>
</view>
</view>
</section>
<!-- 患者评价 -->
<section class="reviews-section">
<view class="reviews-header">
<text class="section-title-plain">患者评价</text>
<!-- <text class="view-all" @click="viewAllReviews">查看全部</text> -->
</view>
<view class="reviews-list">
<view
v-for="review in reviews"
:key="review.id"
class="review-card"
>
<view class="review-header">
<view class="review-avatar">{{ review.patient_initials }}</view>
<view class="review-meta">
<text class="review-name">{{ review.patient_name }}</text>
<view class="review-stars">
<text v-for="i in 5" :key="i" class="star"></text>
</view>
</view>
<text class="review-time">{{ review.create_time }}</text>
</view>
<text class="review-content">"{{ review.content }}"</text>
</view>
</view>
</section>
</view>
<!-- 错误 -->
<view v-else-if="!loading && error" class="error-wrap">
<text class="error-text">{{ error }}</text>
<view class="retry-btn" @click="loadDetail">重试</view>
</view>
<view v-if="doctor">
<!-- 底部操作栏 -->
<view v-if="doctor.enable_image_consult || doctor.enable_video_consult" class="bottom-bar">
<view class="btn-chat" @click="showCardSelector('chat')" v-if="doctor.enable_image_consult">
<text class="btn-icon">💬</text>
<text class="btn-text">图文问诊</text>
</view>
<view class="btn-video" @click="showCardSelector('video')" v-if="doctor.enable_video_consult">
<text class="btn-icon">📹</text>
<text class="btn-text">视频问诊</text>
</view>
</view>
</view>
<!-- 就诊人选择弹窗 -->
<view v-if="showCardModal" class="modal-overlay" @click="closeCardModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">选择就诊人</text>
<text class="modal-close" @click="closeCardModal"></text>
</view>
<view class="modal-body">
<!-- 加载中 -->
<view v-if="loadingCards" class="modal-loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 就诊卡列表 -->
<view v-else-if="cardList.length > 0" class="card-list">
<view
v-for="card in cardList"
:key="card.id"
class="card-item"
@click="selectCard(card)"
>
<view class="card-info">
<text class="card-name">{{ card.patient_name }}</text>
<text class="card-detail">{{ card.gender_desc }} / {{ card.age }}</text>
</view>
<text class="card-arrow"></text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="modal-empty">
<text class="empty-text">暂无就诊卡</text>
<view class="add-card-btn" @click="createNewCard">新建就诊卡</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getCurrentInstance } from 'vue'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
let userSig= ref("");
let SDKAppID=ref("");
uni.CallManager = new CallManager();
const doctorId = ref('')
const doctor = ref(null)
const reviews = ref([])
const loading = ref(true)
const error = ref('')
const defaultAvatar = '/static/user/user.png'
// 就诊卡选择相关
const showCardModal = ref(false)
const cardList = ref([])
const loadingCards = ref(false)
const consultType = ref('') // 'chat' 或 'video'
// 医生资质图片
const qualificationImages = computed(() => {
if (!doctor.value?.qualification_images) return []
try {
const images = typeof doctor.value.qualification_images === 'string'
? JSON.parse(doctor.value.qualification_images)
: doctor.value.qualification_images
return Array.isArray(images) ? images : []
} catch (e) {
console.error('解析资质图片失败:', e)
return []
}
})
const formatPatientCount = (count) => {
if (count === undefined || count === null) return '1.2k+'
if (count >= 1000) return (count / 1000).toFixed(1) + 'k+'
return count + '+'
}
const getExpertiseIcon = (icon) => {
const map = { psychiatry: '🌿', eco: '🌱', vital_signs: '💓' }
return map[icon] || '📋'
}
// 预览资质图片
const previewImage = (current) => {
uni.previewImage({
current: current,
urls: qualificationImages.value
})
}
const loadDetail = async () => {
if (!doctorId.value) return
loading.value = true
error.value = ''
try {
const res = await proxy.apiUrl({
url: '/api/doctor/detail',
method: 'GET',
data: { id: doctorId.value }
}, false)
if (res && res.code === 1) {
doctor.value = res.data
} else {
error.value = res?.msg || '加载失败'
}
} catch (e) {
error.value = '网络异常,请重试'
console.error(e)
} finally {
loading.value = false
}
}
const loadReviews = async () => {
if (!doctorId.value) return
try {
const res = await proxy.apiUrl({
url: '/api/doctor/reviews',
method: 'GET',
data: { doctor_id: doctorId.value, page_no: 1, page_size: 10 }
}, false)
if (res && res.code === 1) {
reviews.value = res.data?.lists || []
}
} catch (e) {
console.error(e)
}
}
const viewAllReviews = () => {
uni.showToast({ title: '查看全部评价', icon: 'none' })
}
// 显示就诊卡选择弹窗
const showCardSelector = async (type) => {
consultType.value = type
showCardModal.value = true
await loadCardList()
}
// 关闭弹窗
const closeCardModal = () => {
showCardModal.value = false
}
// 加载就诊卡列表
const loadCardList = async () => {
loadingCards.value = true
try {
const token = uni.getStorageSync('token')
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' })
closeCardModal()
return
}
const userData = uni.getStorageSync('userData')
const userId = userData?.id
if (!userId) {
uni.showToast({ title: '用户信息不存在', icon: 'none' })
closeCardModal()
return
}
const res = await proxy.apiUrl({
url: '/api/tcm/getCardList',
method: 'GET',
data: { user_id: userId,patient_id:userId }
}, false)
if (res && res.code === 1) {
cardList.value = res.data || []
} else {
uni.showToast({ title: res?.msg || '获取失败', icon: 'none' })
}
} catch (e) {
console.error('加载就诊卡列表失败:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingCards.value = false
}
}
// 选择就诊卡
const selectCard = async (card) => {
closeCardModal()
// 更新本地存储的就诊卡信息
const userData = uni.getStorageSync('userData') || {}
userData.diagnosis = {
patient_id: card.patient_id,
id: card.id,
patient_name: card.patient_name
}
uni.setStorageSync('userData', userData)
// 根据类型跳转
if (consultType.value === 'chat') {
await goChat(card)
} else if (consultType.value === 'video') {
await goChat(card)
}
}
// 新建就诊卡
const createNewCard = () => {
closeCardModal()
uni.navigateTo({
url: `/pages/Card/edit_card?add=1&returnUrl=/doctor/pages/doctor/doctor?id=${doctorId.value}`
})
}
const goChat = async (card) => {
console.log('开始图文问诊', uni.getStorageSync('userData'))
const userData = uni.getStorageSync('userData')
if (!userData.diagnosis) {
uni.showToast({ title: '请先选择就诊人', icon: 'none' })
return
}
await loginHandler(userData.diagnosis.patient_id)
const doctor = 'doctor_' + doctorId.value
uni.navigateTo({
url: `/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId.value)}&userSig=${encodeURIComponent(userSig.value)}&SDKAppID=${SDKAppID.value}&targetUserID=${doctor}&msg=0`
})
}
const loginHandler = async (patient_id) => {
console.log('获取签名开始:', patient_id)
const res = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: { patient_id: patient_id },
}, false)
const { userId: patientUserId } = res.data
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
userID: patientUserId,
})
console.log('获取签名成功:', patientUserSig)
// 更新ref值
userId.value = patientUserId
userSig.value = patientUserSig
SDKAppID.value = patientSDKAppID
getApp().globalData.userID = patientUserId
getApp().globalData.userSig = patientUserSig
getApp().globalData.SDKAppID = patientSDKAppID
getApp().globalData.isIMInitialized = false
await uni.CallManager.init({
sdkAppID: patientSDKAppID,
userID: patientUserId,
userSig: patientUserSig,
globalCallPagePath: "TUICallKit/src/Components/TUICallKit",
})
console.log('初始化完成,跳转到聊天页面')
}
onMounted(() => {
const pages = getCurrentPages()
const current = pages[pages.length - 1]
const options = current.options || current.$page?.options || {}
doctorId.value = options.id || options.doctorId || ''
if (doctorId.value) {
loadDetail().then(() => loadReviews())
} else {
error.value = '缺少医生ID'
loading.value = false
}
})
</script>
<style lang="scss" scoped>
$bg: #faf9f5;
$primary: #204e2b;
$primary-container: #386641;
$on-primary: #ffffff;
$secondary-container: #fdc39a;
$on-secondary-container: #794e2e;
$surface-lowest: #ffffff;
$surface-low: #f4f4f0;
$surface-high: #e9e8e4;
$surface-container: #efeeea;
$on-surface: #1b1c1a;
$on-surface-variant: #414941;
$tertiary-container: #4b6500;
$outline-variant: #c1c9be;
.page {
background: $bg;
min-height: 100vh;
padding-bottom: 180rpx;
}
.loading-wrap, .error-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.loading-text, .error-text {
font-size: 28rpx;
color: $on-surface-variant;
}
.retry-btn {
margin-top: 24rpx;
padding: 16rpx 48rpx;
background: $primary;
color: $on-primary;
border-radius: 40rpx;
font-size: 28rpx;
}
.main {
padding: 32rpx 48rpx 40rpx;
}
.hero-section {
margin-bottom: 80rpx;
}
.hero-content {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.avatar-wrap {
position: relative;
width: 320rpx;
height: 320rpx;
flex-shrink: 0;
}
.avatar {
width: 100%;
height: 100%;
border-radius: 24rpx;
background: $surface-high;
}
.badge {
position: absolute;
bottom: -16rpx;
right: -16rpx;
background: $tertiary-container;
color: $on-primary;
font-size: 22rpx;
padding: 12rpx 24rpx;
border-radius: 9999rpx;
font-weight: bold;
}
.hero-info {
flex: 1;
}
.doctor-name {
display: block;
font-size: 64rpx;
font-weight: bold;
color: $on-surface;
margin-bottom: 16rpx;
font-family: 'Noto Serif SC', 'STSong', 'SimSun', serif;
}
.hospital {
display: block;
font-size: 36rpx;
color: $primary;
font-weight: 500;
margin-bottom: 16rpx;
}
.experience-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 32rpx;
}
.verified {
font-size: 32rpx;
color: $primary;
}
.experience {
font-size: 28rpx;
color: $on-surface-variant;
}
.stats-row {
display: flex;
align-items: center;
gap: 32rpx;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: $on-surface;
font-family: 'Noto Serif SC', serif;
}
.stat-label {
font-size: 20rpx;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $on-surface-variant;
font-weight: bold;
margin-top: 4rpx;
}
.stat-divider {
width: 2rpx;
height: 64rpx;
background: $outline-variant;
opacity: 0.3;
}
.about-section {
margin-bottom: 96rpx;
}
.section-title-bar {
padding-left: 32rpx;
border-left: 8rpx solid $secondary-container;
margin-bottom: 32rpx;
}
.section-title {
font-size: 48rpx;
font-weight: bold;
color: $on-surface;
font-family: 'Noto Serif SC', serif;
}
.about-content {
background: $surface-low;
padding: 48rpx;
border-radius: 24rpx;
}
.about-text {
font-size: 28rpx;
color: $on-surface-variant;
line-height: 1.8;
}
.qualification-section {
margin-bottom: 96rpx;
}
.qualification-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.qualification-image {
width: 100%;
height: 200rpx;
border-radius: 16rpx;
background: $surface-high;
object-fit: cover;
}
.expertise-section {
margin-bottom: 96rpx;
}
.section-title-plain {
display: block;
font-size: 48rpx;
font-weight: bold;
color: $on-surface;
margin-bottom: 48rpx;
font-family: 'Noto Serif SC', serif;
}
.expertise-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
}
.expertise-card {
background: $surface-lowest;
padding: 40rpx;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.expertise-card.expertise-wide {
grid-column: span 2;
flex-direction: row;
align-items: center;
gap: 48rpx;
}
.expertise-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ffdcc5;
display: flex;
align-items: center;
justify-content: center;
}
.expertise-icon.icon-eco {
background: #ccf078;
}
.expertise-icon.icon-vital_signs {
width: 96rpx;
height: 96rpx;
background: #bcefc0;
}
.icon-emoji {
font-size: 40rpx;
}
.expertise-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: $on-surface;
margin-bottom: 8rpx;
}
.expertise-desc {
font-size: 24rpx;
color: $on-surface-variant;
line-height: 1.5;
}
.reviews-section {
margin-bottom: 48rpx;
}
.reviews-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 48rpx;
}
.view-all {
font-size: 28rpx;
color: $primary;
font-weight: bold;
}
.reviews-list {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.review-card {
background: $surface-lowest;
padding: 48rpx;
border-radius: 24rpx;
}
.review-header {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 24rpx;
}
.review-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $surface-container;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: bold;
color: $on-surface-variant;
}
.review-meta {
flex: 1;
}
.review-name {
display: block;
font-size: 28rpx;
font-weight: bold;
color: $on-surface;
margin-bottom: 8rpx;
}
.review-stars {
color: #805533;
font-size: 24rpx;
}
.review-time {
font-size: 24rpx;
color: $on-surface-variant;
}
.review-content {
font-size: 28rpx;
color: $on-surface-variant;
line-height: 1.6;
font-style: italic;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 48rpx;
display: flex;
gap: 32rpx;
background: rgba(250, 249, 245, 0.95);
z-index: 50;
}
.btn-chat {
flex: 1;
height: 112rpx;
background: $secondary-container;
color: $on-secondary-container;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
font-weight: bold;
font-size: 30rpx;
}
.btn-video {
flex: 1;
height: 112rpx;
background: linear-gradient(135deg, $primary 0%, $primary-container 100%);
color: $on-primary;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
font-weight: bold;
font-size: 30rpx;
}
.btn-icon {
font-size: 40rpx;
}
/* 弹窗样式 */
.modal-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: 1000;
}
.modal-content {
width: 640rpx;
max-height: 80vh;
background: $surface-lowest;
border-radius: 32rpx;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 48rpx;
border-bottom: 2rpx solid $outline-variant;
}
.modal-title {
font-size: 40rpx;
font-weight: bold;
color: $on-surface;
}
.modal-close {
font-size: 48rpx;
color: $on-surface-variant;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
max-height: 60vh;
overflow-y: auto;
}
.modal-loading {
padding: 120rpx 0;
text-align: center;
}
.card-list {
padding: 24rpx;
}
.card-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
margin-bottom: 16rpx;
background: $surface-low;
border-radius: 16rpx;
transition: all 0.3s;
&:active {
background: $surface-high;
transform: scale(0.98);
}
}
.card-info {
flex: 1;
}
.card-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: $on-surface;
margin-bottom: 8rpx;
}
.card-detail {
display: block;
font-size: 28rpx;
color: $on-surface-variant;
}
.card-arrow {
font-size: 48rpx;
color: $on-surface-variant;
margin-left: 16rpx;
}
.modal-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 48rpx;
}
.empty-text {
font-size: 32rpx;
color: $on-surface-variant;
margin-bottom: 48rpx;
}
.add-card-btn {
padding: 24rpx 64rpx;
background: $primary;
color: $on-primary;
border-radius: 40rpx;
font-size: 32rpx;
font-weight: bold;
}
</style>